From 39187dff7a16f8426855356c592203fdc535a9b4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:28:28 +0900 Subject: [PATCH 1/9] feat: Let a lease be cancelled explicitly from outside its request Each execution lease now owns an explicit cancellation source linked with the request token, and tools are meant to run with the linked ExecutionToken. ToolExecutionSession.CancelRunning fires every lease whose request may be cancelled by uloop cancel, outside the session lock because Cancel() runs the cancelled requests' continuations and their Release on the calling thread. Leases issued for calls that did not come from the CLI report NotCancellable and are left running. The execute-dynamic-code grace revocation now watches the execution token, so a lease cancelled only by uloop cancel is also taken back after 5 seconds. --- .../Tests/Editor/ToolExecutionSessionTests.cs | 134 ++++++++++++++++++ .../Editor/Domain/ToolCancelRequestResult.cs | 63 ++++++++ .../Domain/ToolCancelRequestResult.cs.meta | 11 ++ .../src/Editor/Domain/ToolExecutionSession.cs | 103 +++++++++++--- 4 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 Packages/src/Editor/Domain/ToolCancelRequestResult.cs create mode 100644 Packages/src/Editor/Domain/ToolCancelRequestResult.cs.meta diff --git a/Assets/Tests/Editor/ToolExecutionSessionTests.cs b/Assets/Tests/Editor/ToolExecutionSessionTests.cs index 2399d8586d..054d055c49 100644 --- a/Assets/Tests/Editor/ToolExecutionSessionTests.cs +++ b/Assets/Tests/Editor/ToolExecutionSessionTests.cs @@ -718,6 +718,140 @@ public void GetSnapshot_AfterHolderDisposesLease_ShouldReportIdle() Assert.That(snapshot.IsBusy, Is.False); } + /// + /// Verifies cancelling with nothing in the slot reports NothingRunning without a running tool. + /// + [Test] + public void CancelRunning_WhenIdle_ReportsNothingRunning() + { + ToolExecutionSession session = new ToolExecutionSession(); + + ToolCancelRequestResult result = session.CancelRunning(); + + Assert.That(result.Outcome, Is.EqualTo(ToolCancelRequestOutcome.NothingRunning)); + Assert.That(result.RunningToolName, Is.Null); + Assert.That(result.RunningToolElapsedSeconds, Is.Null); + } + + /// + /// Verifies cancelling a cancellable holder fires its execution token, marks it as cancelled + /// by the user, and reports the running tool with its elapsed seconds. + /// + [Test] + public void CancelRunning_WhenCancellableLeaseRuns_FiresExecutionTokenAndReportsCancelled() + { + long timestamp = 0; + ToolExecutionSession session = new ToolExecutionSession(() => timestamp); + ToolExecutionLease lease = session.TryEnter("run-tests").Lease; + timestamp += 3 * Stopwatch.Frequency; + + ToolCancelRequestResult result = session.CancelRunning(); + + Assert.That(result.Outcome, Is.EqualTo(ToolCancelRequestOutcome.Cancelled)); + Assert.That(result.RunningToolName, Is.EqualTo("run-tests")); + Assert.That(result.RunningToolElapsedSeconds, Is.EqualTo(3)); + Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.True); + Assert.That(lease.WasCancelledByUser, Is.True); + + lease.Dispose(); + } + + /// + /// Verifies a holder whose request must not be cancelled is left running: the outcome is + /// NotCancellable and its execution token does not fire. + /// + [Test] + public void CancelRunning_WhenOnlyNonCancellableLease_DoesNotCancel() + { + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease lease = session.TryEnter("compile", cancellableByUser: false).Lease; + + ToolCancelRequestResult result = session.CancelRunning(); + + Assert.That(result.Outcome, Is.EqualTo(ToolCancelRequestOutcome.NotCancellable)); + Assert.That(result.RunningToolName, Is.EqualTo("compile")); + Assert.That(result.RunningToolElapsedSeconds, Is.Not.Null); + Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False); + Assert.That(lease.WasCancelledByUser, Is.False); + + lease.Dispose(); + } + + /// + /// Verifies cancelling a shared execute-dynamic-code slot fires every lease that holds it. + /// + [Test] + public void CancelRunning_WithTwoSharedLeases_CancelsBoth() + { + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease firstLease = session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE).Lease; + ToolExecutionLease secondLease = session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE).Lease; + + ToolCancelRequestResult result = session.CancelRunning(); + + Assert.That(result.Outcome, Is.EqualTo(ToolCancelRequestOutcome.Cancelled)); + Assert.That(firstLease.ExecutionToken.IsCancellationRequested, Is.True); + Assert.That(secondLease.ExecutionToken.IsCancellationRequested, Is.True); + + firstLease.Dispose(); + secondLease.Dispose(); + } + + /// + /// Verifies a lease's execution token also fires when only its request token is cancelled, + /// without marking it as cancelled by the user. + /// + [Test] + public void ExecutionToken_WhenRequestTokenIsCancelled_FiresWithoutUserCancellation() + { + ToolExecutionSession session = new ToolExecutionSession(); + using CancellationTokenSource requestCancellation = new CancellationTokenSource(); + ToolExecutionLease lease = session.TryEnter("run-tests", requestCancellation.Token).Lease; + + requestCancellation.Cancel(); + + Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.True); + Assert.That(lease.WasCancelledByUser, Is.False); + + lease.Dispose(); + } + + /// + /// Verifies a uloop cancel that races the holder's release does not throw on the released lease. + /// + [Test] + public void CancelByUser_AfterLeaseIsDisposed_DoesNotThrow() + { + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease lease = session.TryEnter("run-tests").Lease; + lease.Dispose(); + + Assert.DoesNotThrow(() => lease.CancelByUser()); + } + + /// + /// Verifies an execute-dynamic-code holder cancelled only by uloop cancel is taken back once + /// it outlives the grace period, the same as one whose client disconnected. + /// + [Test] + public void TryEnter_WhenDynamicCodeHolderIsCancelledByUserPastGrace_ShouldLetOtherToolEnter() + { + long timestamp = 0; + ToolExecutionSession session = new ToolExecutionSession(() => timestamp); + ToolExecutionLease holderLease = session.TryEnter(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE).Lease; + session.CancelRunning(); + + ToolExecutionSessionEnterResult firstAttempt = session.TryEnter("other-tool"); + timestamp += GraceTicks; + ToolExecutionSessionEnterResult secondAttempt = session.TryEnter("other-tool"); + + Assert.That(firstAttempt.IsEntered, Is.False); + Assert.That(secondAttempt.IsEntered, Is.True); + + secondAttempt.Lease.Dispose(); + holderLease.Dispose(); + } + private static long GraceTicks => ToolExecutionSession.CancelledLeaseGraceSeconds * Stopwatch.Frequency; private static UnityCliLoopToolRegistry CreateRegistry(InMemoryToolSettingsPort settingsPort, IUnityCliLoopTool[] tools) diff --git a/Packages/src/Editor/Domain/ToolCancelRequestResult.cs b/Packages/src/Editor/Domain/ToolCancelRequestResult.cs new file mode 100644 index 0000000000..29f6276d15 --- /dev/null +++ b/Packages/src/Editor/Domain/ToolCancelRequestResult.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Domain +{ + /// + /// Reports what a uloop cancel request did to the execution slot. The running-tool fields + /// are null when nothing was running. + /// + internal sealed class ToolCancelRequestResult + { + private ToolCancelRequestResult( + ToolCancelRequestOutcome outcome, + string runningToolName, + int? runningToolElapsedSeconds) + { + Outcome = outcome; + RunningToolName = runningToolName; + RunningToolElapsedSeconds = runningToolElapsedSeconds; + } + + internal ToolCancelRequestOutcome Outcome { get; } + + internal string RunningToolName { get; } + + internal int? RunningToolElapsedSeconds { get; } + + internal static ToolCancelRequestResult NothingRunning() + { + return new ToolCancelRequestResult(ToolCancelRequestOutcome.NothingRunning, null, null); + } + + internal static ToolCancelRequestResult Cancelled(string runningToolName, int runningToolElapsedSeconds) + { + Debug.Assert(!string.IsNullOrWhiteSpace(runningToolName), "runningToolName must not be null or whitespace"); + + return new ToolCancelRequestResult( + ToolCancelRequestOutcome.Cancelled, + runningToolName, + runningToolElapsedSeconds); + } + + internal static ToolCancelRequestResult NotCancellable(string runningToolName, int runningToolElapsedSeconds) + { + Debug.Assert(!string.IsNullOrWhiteSpace(runningToolName), "runningToolName must not be null or whitespace"); + + return new ToolCancelRequestResult( + ToolCancelRequestOutcome.NotCancellable, + runningToolName, + runningToolElapsedSeconds); + } + } + + /// + /// What a uloop cancel request found in the execution slot. Member names are the wire values + /// of the cancel response's Outcome. + /// + internal enum ToolCancelRequestOutcome + { + Cancelled, + NothingRunning, + NotCancellable + } +} diff --git a/Packages/src/Editor/Domain/ToolCancelRequestResult.cs.meta b/Packages/src/Editor/Domain/ToolCancelRequestResult.cs.meta new file mode 100644 index 0000000000..9c5c5bceac --- /dev/null +++ b/Packages/src/Editor/Domain/ToolCancelRequestResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7290d7bb4ea214805ae59f7741941358 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/ToolExecutionSession.cs b/Packages/src/Editor/Domain/ToolExecutionSession.cs index 2edce2f5f4..b67ccf7827 100644 --- a/Packages/src/Editor/Domain/ToolExecutionSession.cs +++ b/Packages/src/Editor/Domain/ToolExecutionSession.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -29,7 +30,11 @@ internal ToolExecutionSession(Func timestampProvider = null) _timestampProvider = timestampProvider ?? Stopwatch.GetTimestamp; } - internal ToolExecutionSessionBeginResult Begin(UnityCliLoopToolRegistry registry, string toolName, CancellationToken ct) + internal ToolExecutionSessionBeginResult Begin( + UnityCliLoopToolRegistry registry, + string toolName, + CancellationToken ct, + bool cancellableByUser = true) { Debug.Assert(registry != null, "registry must not be null"); Debug.Assert(!string.IsNullOrWhiteSpace(toolName), "toolName must not be null or whitespace"); @@ -50,7 +55,7 @@ internal ToolExecutionSessionBeginResult Begin(UnityCliLoopToolRegistry registry throw new UnityCliLoopSecurityException(toolName, "Tool is blocked by security settings"); } - ToolExecutionSessionEnterResult enterResult = TryEnter(toolName, ct); + ToolExecutionSessionEnterResult enterResult = TryEnter(toolName, ct, cancellableByUser); if (!enterResult.IsEntered) { return ToolExecutionSessionBeginResult.Busy( @@ -62,7 +67,10 @@ internal ToolExecutionSessionBeginResult Begin(UnityCliLoopToolRegistry registry return ToolExecutionSessionBeginResult.Entered(tool, enterResult.Lease); } - internal ToolExecutionSessionEnterResult TryEnter(string requestedToolName, CancellationToken ct = default) + internal ToolExecutionSessionEnterResult TryEnter( + string requestedToolName, + CancellationToken ct = default, + bool cancellableByUser = true) { Debug.Assert(!string.IsNullOrWhiteSpace(requestedToolName), "requestedToolName must not be null or whitespace"); @@ -71,7 +79,7 @@ internal ToolExecutionSessionEnterResult TryEnter(string requestedToolName, Canc lock (_executionStateLock) { revokedLeases = RevokeCancelledLeasesPastGraceInsideLock(requestedToolName); - result = TryEnterInsideLock(requestedToolName, ct); + result = TryEnterInsideLock(requestedToolName, ct, cancellableByUser); } // Why not log inside the lock: the log call writes a file, and every other request @@ -80,20 +88,23 @@ internal ToolExecutionSessionEnterResult TryEnter(string requestedToolName, Canc return result; } - private ToolExecutionSessionEnterResult TryEnterInsideLock(string requestedToolName, CancellationToken ct) + private ToolExecutionSessionEnterResult TryEnterInsideLock( + string requestedToolName, + CancellationToken ct, + bool cancellableByUser) { if (_activeLeases.Count == 0) { _runningToolName = requestedToolName; _runningStartedTimestamp = _timestampProvider(); - return ToolExecutionSessionEnterResult.Entered(IssueLeaseInsideLock(requestedToolName, ct)); + return ToolExecutionSessionEnterResult.Entered(IssueLeaseInsideLock(requestedToolName, ct, cancellableByUser)); } if (CanShareExecutionSlot(_runningToolName, requestedToolName)) { // Why not refresh the start timestamp: shared-slot re-entry is the same // flight, so elapsed must keep measuring from the first Begin. - return ToolExecutionSessionEnterResult.Entered(IssueLeaseInsideLock(requestedToolName, ct)); + return ToolExecutionSessionEnterResult.Entered(IssueLeaseInsideLock(requestedToolName, ct, cancellableByUser)); } return ToolExecutionSessionEnterResult.Busy( @@ -182,6 +193,40 @@ internal ToolExecutionSessionSnapshot GetSnapshot() } } + // Fires the explicit cancellation of every lease whose request may be cancelled by uloop + // cancel. Why the leases are copied under the lock and cancelled outside it: Cancel() runs + // the cancelled requests' continuations and their lease Release on this thread, and Release + // takes this lock. + internal ToolCancelRequestResult CancelRunning() + { + List targets; + ToolCancelRequestResult result; + lock (_executionStateLock) + { + if (_activeLeases.Count == 0) + { + return ToolCancelRequestResult.NothingRunning(); + } + + string toolName = GetRunningToolNameInsideLock(); + int elapsedSeconds = GetRunningToolElapsedSecondsInsideLock(); + targets = _activeLeases.Where(lease => lease.CancellableByUser).ToList(); + if (targets.Count == 0) + { + return ToolCancelRequestResult.NotCancellable(toolName, elapsedSeconds); + } + + result = ToolCancelRequestResult.Cancelled(toolName, elapsedSeconds); + } + + foreach (ToolExecutionLease lease in targets) + { + lease.CancelByUser(); + } + + return result; + } + private static void LogRevokedLeases(List revokedLeases) { if (revokedLeases == null) @@ -227,9 +272,9 @@ internal void Release(ToolExecutionLease lease) } } - private ToolExecutionLease IssueLeaseInsideLock(string toolName, CancellationToken ct) + private ToolExecutionLease IssueLeaseInsideLock(string toolName, CancellationToken ct, bool cancellableByUser) { - ToolExecutionLease lease = new ToolExecutionLease(this, toolName, ct, _timestampProvider()); + ToolExecutionLease lease = new ToolExecutionLease(this, toolName, ct, _timestampProvider(), cancellableByUser); _activeLeases.Add(lease); return lease; } @@ -261,7 +306,10 @@ private int GetRunningToolElapsedSecondsInsideLock() internal sealed class ToolExecutionLease : IDisposable { private readonly ToolExecutionSession _session; - private readonly CancellationToken _requestCancellation; + // Why never disposed: it has no timer, so it holds nothing to leak, and leaving it alive + // lets a uloop cancel that races the lease's Release call Cancel() without throwing. + private readonly CancellationTokenSource _explicitCancellation = new(); + private readonly CancellationTokenSource _executionCancellation; private long? _cancellationObservedTimestamp; // A count, not a flag: one request can have several switches queued at once (WhenAll). private int _pendingMainThreadWaits; @@ -270,17 +318,31 @@ internal ToolExecutionLease( ToolExecutionSession session, string toolName, CancellationToken requestCancellation, - long issuedTimestamp) + long issuedTimestamp, + bool cancellableByUser) { Debug.Assert(session != null, "session must not be null"); Debug.Assert(!string.IsNullOrWhiteSpace(toolName), "toolName must not be null or whitespace"); _session = session; ToolName = toolName; - _requestCancellation = requestCancellation; + _executionCancellation = CancellationTokenSource.CreateLinkedTokenSource( + requestCancellation, + _explicitCancellation.Token); IssuedTimestamp = issuedTimestamp; + CancellableByUser = cancellableByUser; } + // False for calls that did not come from the CLI: their callers manage cancellation with + // their own token, and uloop cancel only stops what a CLI asked for. + internal bool CancellableByUser { get; } + + // Fires when the request is cancelled (client disconnect, server stop) or uloop cancel + // fires this lease. Tools run with this token instead of the raw request token. + internal CancellationToken ExecutionToken => _executionCancellation.Token; + + internal bool WasCancelledByUser => _explicitCancellation.IsCancellationRequested; + internal string ToolName { get; } internal long IssuedTimestamp { get; } @@ -301,14 +363,23 @@ internal void MarkMainThreadWaitEnded() Interlocked.Decrement(ref _pendingMainThreadWaits); } + // Runs other requests' continuations and this lease's Release synchronously on the + // calling thread (MainThreadSwitcher registers on the token), so never call it under the + // session lock or inside an activity hold. + internal void CancelByUser() + { + _explicitCancellation.Cancel(); + } + public void Dispose() { _session.Release(this); + _executionCancellation.Dispose(); } - // Records the first time a waiting request sees this lease's request cancelled, and - // reports whether the lease has stayed cancelled for the whole grace period since then. - // Called only under the session lock. + // Records the first time a waiting request sees this lease cancelled, by its client or by + // uloop cancel, and reports whether the lease has stayed cancelled for the whole grace + // period since then. Called only under the session lock. internal bool ObserveCancellationPastGrace(long now, long graceTicks) { if (ToolName != UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE) @@ -316,7 +387,7 @@ internal bool ObserveCancellationPastGrace(long now, long graceTicks) return false; } - if (!_requestCancellation.IsCancellationRequested) + if (!ExecutionToken.IsCancellationRequested) { return false; } From 8916e6c58ce9dedf8e327aa344ea56c22b07bc63 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:31:40 +0900 Subject: [PATCH 2/9] feat: Report a tool stopped by uloop cancel as its own error Tools, the main-thread switch, and the cancellation check now run with the lease's ExecutionToken, so uloop cancel reaches a tool the same way a client disconnect does. When the explicit cancellation fired and the request token did not, the resulting OperationCanceledException becomes UnityCliLoopToolCancelledException, which the protocol layer can answer instead of dropping the connection. When the client is gone as well, the exception stays an OperationCanceledException because nobody is left to read an answer. The public IUnityCliLoopToolRegistrar.ExecuteToolAsync keeps its signature and starts tools that uloop cancel cannot stop, because its callers are inside the Editor and cancel with their own token. CLI requests go through the new internal ExecuteRequestedToolAsync, which takes the cancellable flag from the request. --- .../UnityCliLoopToolExecutionServiceTests.cs | 243 ++++++++++++++++++ .../Application/ImprovedErrorHandler.cs | 13 + .../UnityCliLoopCancelRequestResult.cs | 36 +++ .../UnityCliLoopCancelRequestResult.cs.meta | 11 + .../UnityCliLoopToolCancelledException.cs | 26 ++ ...UnityCliLoopToolCancelledException.cs.meta | 11 + .../UnityCliLoopToolExecutionService.cs | 30 ++- .../Application/UnityCliLoopToolRegistrar.cs | 31 ++- 8 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs create mode 100644 Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs.meta create mode 100644 Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs create mode 100644 Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs.meta diff --git a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs index 62e01f4e2b..90575c3970 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolExecutionServiceTests.cs @@ -288,6 +288,196 @@ public async Task ExecuteToolAsync_WhenDynamicCodeRequestIsCancelledAndNeverRetu } } + /// + /// Verifies a tool that honours its token ends with UnityCliLoopToolCancelledException when + /// uloop cancel fires, and that the slot is free again afterwards. + /// + [Test] + public async Task ExecuteToolAsync_WhenCancelledByUser_ThrowsToolCancelledAndReleasesSlot() + { + ToolExecutionSession session = new ToolExecutionSession(); + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + CooperativeTool tool = new CooperativeTool(); + registry.RegisterTool(tool); + UnityCliLoopToolExecutionService executionService = + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), session); + MainThreadSwitcher.RegisterService(new ImmediateMainThreadDispatcher()); + + try + { + Task runningTask = executionService.ExecuteToolAsync( + registry, + CooperativeTool.ToolNameValue, + null, + CancellationToken.None); + + UnityCliLoopCancelRequestResult cancelResult = executionService.CancelRunningTool(); + await AwaitCompletionWithTimeout(runningTask); + + Assert.That(cancelResult.Outcome, Is.EqualTo("Cancelled")); + Assert.That(cancelResult.RunningToolName, Is.EqualTo(CooperativeTool.ToolNameValue)); + UnityCliLoopToolCancelledException exception = + Assert.ThrowsAsync(() => runningTask); + Assert.That(exception.ToolName, Is.EqualTo(CooperativeTool.ToolNameValue)); + ToolExecutionSessionEnterResult nextEnter = session.TryEnter("other-tool"); + Assert.That(nextEnter.IsEntered, Is.True); + nextEnter.Lease.Dispose(); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + + /// + /// Verifies a request still waiting for the stalled Editor main thread is resumed by uloop + /// cancel, ends with UnityCliLoopToolCancelledException, and frees the slot without the main + /// thread ever running it. + /// + [Test] + public async Task ExecuteToolAsync_WhenCancelledByUserWhileWaitingForMainThread_ThrowsToolCancelledAndReleasesSlot() + { + ToolExecutionSession session = new ToolExecutionSession(); + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + registry.RegisterTool(new ImmediateTool(ImmediateTool.HolderName)); + UnityCliLoopToolExecutionService executionService = + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), session); + QueueingMainThreadDispatcher dispatcher = new QueueingMainThreadDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + + try + { + Task waitingTask = executionService.ExecuteToolAsync( + registry, + ImmediateTool.HolderName, + null, + CancellationToken.None); + Assert.That(waitingTask.IsCompleted, Is.False); + + executionService.CancelRunningTool(); + await AwaitCompletionWithTimeout(waitingTask); + + Assert.ThrowsAsync(() => waitingTask); + ToolExecutionSessionEnterResult nextEnter = session.TryEnter("other-tool"); + Assert.That(nextEnter.IsEntered, Is.True); + nextEnter.Lease.Dispose(); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + + /// + /// Verifies a request whose client also disconnected after uloop cancel keeps ending with the + /// plain OperationCanceledException, because nobody is left to read a cancel error. + /// + [Test] + public async Task ExecuteToolAsync_WhenRequestTokenAlsoCancelledAfterUserCancel_ThrowsOperationCanceled() + { + ToolExecutionSession session = new ToolExecutionSession(); + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + ManuallyCancelledTool tool = new ManuallyCancelledTool(); + registry.RegisterTool(tool); + UnityCliLoopToolExecutionService executionService = + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), session); + MainThreadSwitcher.RegisterService(new ImmediateMainThreadDispatcher()); + using CancellationTokenSource requestCancellation = new CancellationTokenSource(); + + try + { + Task runningTask = executionService.ExecuteToolAsync( + registry, + ManuallyCancelledTool.ToolNameValue, + null, + requestCancellation.Token); + + executionService.CancelRunningTool(); + requestCancellation.Cancel(); + tool.EndWithCancellation(); + await AwaitCompletionWithTimeout(runningTask); + + Assert.CatchAsync(() => runningTask); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + + /// + /// Verifies a client disconnect alone still ends the request with OperationCanceledException, + /// not with the uloop cancel error. + /// + [Test] + public async Task ExecuteToolAsync_WhenOnlyRequestTokenIsCancelled_ThrowsOperationCanceled() + { + ToolExecutionSession session = new ToolExecutionSession(); + UnityCliLoopToolRegistry registry = ToolRegistryTestFactory.Create(); + registry.RegisterTool(new CooperativeTool()); + UnityCliLoopToolExecutionService executionService = + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), session); + MainThreadSwitcher.RegisterService(new ImmediateMainThreadDispatcher()); + using CancellationTokenSource requestCancellation = new CancellationTokenSource(); + + try + { + Task runningTask = executionService.ExecuteToolAsync( + registry, + CooperativeTool.ToolNameValue, + null, + requestCancellation.Token); + + requestCancellation.Cancel(); + await AwaitCompletionWithTimeout(runningTask); + + Exception exception = Assert.CatchAsync(() => runningTask); + Assert.That(exception, Is.Not.InstanceOf()); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + + /// + /// Verifies a tool started through the public registrar entry point, which Editor code uses + /// with its own token, is reported NotCancellable and keeps running when uloop cancel arrives. + /// + [Test] + public async Task CancelRunningTool_WhenToolRunsThroughPublicRegistrarEntryPoint_ReportsNotCancellable() + { + PendingTypedTool tool = new PendingTypedTool(); + UnityCliLoopToolRegistrarService registrarService = new UnityCliLoopToolRegistrarService( + new EmptyInternalToolNameProvider(), + new AlwaysEnabledToolSettingsPort(), + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), new ToolExecutionSession()), + () => new IUnityCliLoopTool[] { tool }); + MainThreadSwitcher.RegisterService(new ImmediateMainThreadDispatcher()); + + try + { + Task runningTask = registrarService.ExecuteToolAsync( + PendingTypedTool.Name, + null, + CancellationToken.None); + + UnityCliLoopCancelRequestResult cancelResult = registrarService.CancelRunningTool(); + + Assert.That(cancelResult.Outcome, Is.EqualTo("NotCancellable")); + Assert.That(cancelResult.RunningToolName, Is.EqualTo(PendingTypedTool.Name)); + Assert.That(runningTask.IsCompleted, Is.False); + + tool.Complete(); + UnityCliLoopToolResponse response = await AwaitResponseWithTimeout(runningTask, CancellationToken.None); + Assert.That(response, Is.InstanceOf()); + } + finally + { + RestoreEditorMainThreadDispatcher(); + } + } + [Test] public void ScreenshotUseCase_CreateTimedOutResult_PreservesTimeoutDetails() { @@ -330,6 +520,15 @@ private static async Task AwaitResponseWithTimeout( return await task; } + // Waits at most one second so a tool that ignores the cancellation fails the test instead of + // hanging the Editor. The task's own outcome is asserted by the caller. + private static async Task AwaitCompletionWithTimeout(Task task) + { + Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(1)); + Task completedTask = await Task.WhenAny(task, timeoutTask); + Assert.That(completedTask, Is.SameAs(task), "Tool execution did not complete before the test timeout."); + } + private sealed class CapturingMainThreadDispatcher : IMainThreadDispatcher { public bool IsMainThread => false; @@ -454,6 +653,50 @@ public void Complete() } } + // Ends with a cancellation as soon as the token it receives fires, as a tool that honours ct does. + private sealed class CooperativeTool : IUnityCliLoopTool + { + public const string ToolNameValue = "cooperative-tool"; + + public string ToolName => ToolNameValue; + + public ToolParameterSchema ParameterSchema => new(); + + public Task ExecuteAsync(JToken paramsToken, CancellationToken ct) + { + TaskCompletionSource completion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ct.Register(() => completion.TrySetCanceled(ct)); + return completion.Task; + } + } + + // Ends with a cancellation only when the test says so, so the test can change the request + // token between the cancel and the moment the tool gives up. + private sealed class ManuallyCancelledTool : IUnityCliLoopTool + { + public const string ToolNameValue = "manually-cancelled-tool"; + + private readonly TaskCompletionSource _completion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private CancellationToken _receivedToken; + + public string ToolName => ToolNameValue; + + public ToolParameterSchema ParameterSchema => new(); + + public Task ExecuteAsync(JToken paramsToken, CancellationToken ct) + { + _receivedToken = ct; + return _completion.Task; + } + + public void EndWithCancellation() + { + _completion.TrySetCanceled(_receivedToken); + } + } + // Stands in for an execute-dynamic-code run stuck on an await that ignores cancellation. private sealed class NeverReturningDynamicCodeTool : IUnityCliLoopTool { diff --git a/Packages/src/Editor/Application/ImprovedErrorHandler.cs b/Packages/src/Editor/Application/ImprovedErrorHandler.cs index c7f33cc3b3..b67a3ac36f 100644 --- a/Packages/src/Editor/Application/ImprovedErrorHandler.cs +++ b/Packages/src/Editor/Application/ImprovedErrorHandler.cs @@ -79,6 +79,19 @@ public TranslationOutput TranslateFromException(Exception exception) }; } + if (exception is UnityCliLoopToolCancelledException cancelledException) + { + return new TranslationOutput + { + FriendlyMessage = cancelledException.Message, + Explanation = cancelledException.Message, + Solutions = new List + { + "Run uloop status to confirm the Editor is Ready before the next command" + } + }; + } + if (exception is TimeoutException) { return new TranslationOutput diff --git a/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs b/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs new file mode 100644 index 0000000000..dda66ef418 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs @@ -0,0 +1,36 @@ +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// What a uloop cancel request did to the execution slot, in values that layers outside Domain + /// can read. Outcome is Cancelled, NothingRunning, or NotCancellable; the running-tool fields + /// are null when nothing was running. + /// + internal sealed class UnityCliLoopCancelRequestResult + { + private UnityCliLoopCancelRequestResult(ToolCancelRequestResult result) + { + Outcome = result.Outcome.ToString(); + FiredCancellation = result.Outcome == ToolCancelRequestOutcome.Cancelled; + RunningToolName = result.RunningToolName; + RunningToolElapsedSeconds = result.RunningToolElapsedSeconds; + } + + internal string Outcome { get; } + + // True only when the request fired the cancellation of a running tool. + internal bool FiredCancellation { get; } + + internal string RunningToolName { get; } + + internal int? RunningToolElapsedSeconds { get; } + + internal static UnityCliLoopCancelRequestResult From(ToolCancelRequestResult result) + { + UnityEngine.Debug.Assert(result != null, "result must not be null"); + + return new UnityCliLoopCancelRequestResult(result); + } + } +} diff --git a/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs.meta b/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs.meta new file mode 100644 index 0000000000..19bae7c440 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopCancelRequestResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06006dbfac52c4cb4b6222e26249c8cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs b/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs new file mode 100644 index 0000000000..56b4c2638b --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs @@ -0,0 +1,26 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Reports that uloop cancel stopped the tool a request was running, so the protocol layer can + /// answer the waiting CLI with a tool_cancelled error instead of dropping the connection. + /// Why not an OperationCanceledException: the request pipeline treats that as a disconnect and + /// writes no response. + /// + public sealed class UnityCliLoopToolCancelledException : Exception + { + public UnityCliLoopToolCancelledException(string toolName) + : base(CreateMessage(toolName)) + { + ToolName = toolName; + } + + public string ToolName { get; } + + private static string CreateMessage(string toolName) + { + return $"'{toolName}' was cancelled by uloop cancel."; + } + } +} diff --git a/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs.meta b/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs.meta new file mode 100644 index 0000000000..fccaf98e22 --- /dev/null +++ b/Packages/src/Editor/Application/UnityCliLoopToolCancelledException.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae3354ee1e35b4f688314088b2febc2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs index 8f046279b9..ec49fe3816 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolExecutionService.cs @@ -31,14 +31,15 @@ internal async Task ExecuteToolAsync( UnityCliLoopToolRegistry registry, string toolName, JToken paramsToken, - CancellationToken ct) + CancellationToken ct, + bool cancellableByUser = true) { Debug.Assert(registry != null, "registry must not be null"); Debug.Assert(!string.IsNullOrWhiteSpace(toolName), "toolName must not be null or whitespace"); ct.ThrowIfCancellationRequested(); - ToolExecutionSessionBeginResult beginResult = _executionSession.Begin(registry, toolName, ct); + ToolExecutionSessionBeginResult beginResult = _executionSession.Begin(registry, toolName, ct, cancellableByUser); if (!beginResult.IsEntered) { throw CreateBusyException( @@ -49,18 +50,20 @@ internal async Task ExecuteToolAsync( beginResult.RunningToolPhase?.ToString()); } + ToolExecutionLease lease = beginResult.Lease; + CancellationToken executionToken = lease.ExecutionToken; try { // Why an async-flow observer instead of marking phases here: the request can also // wait for the main thread inside the tool, after its work is done (for example // execute-dynamic-code reading the pause state), and only the switch itself sees that. // The value set here flows into the tool and is restored for this method's caller. - MainThreadWaitObservation.SetCurrent(new ToolExecutionLeaseWaitObserver(beginResult.Lease)); - await MainThreadSwitcher.SwitchToMainThread(ct); - ct.ThrowIfCancellationRequested(); + MainThreadWaitObservation.SetCurrent(new ToolExecutionLeaseWaitObserver(lease)); + await MainThreadSwitcher.SwitchToMainThread(executionToken); + executionToken.ThrowIfCancellationRequested(); UnityCliLoopEditorStateGuard.Validate(toolName, _editorRuntimeStatePort); - UnityCliLoopToolResponse response = await beginResult.Tool.ExecuteAsync(paramsToken, ct).ConfigureAwait(false); + UnityCliLoopToolResponse response = await beginResult.Tool.ExecuteAsync(paramsToken, executionToken).ConfigureAwait(false); if (response == null) { throw new InvalidOperationException($"Tool returned null response: {toolName}"); @@ -68,12 +71,25 @@ internal async Task ExecuteToolAsync( return response; } + // Why convert: an explicit uloop cancel must reach the waiting CLI as its own error, while a + // disconnect (the request token) still drops the connection because nobody is left to answer. + catch (OperationCanceledException) when (lease.WasCancelledByUser && !ct.IsCancellationRequested) + { + throw new UnityCliLoopToolCancelledException(toolName); + } finally { - beginResult.Lease.Dispose(); + lease.Dispose(); } } + // Why convert here: Infrastructure cannot see the Domain result type, so the outcome leaves + // in an Application type, the same way GetExecutionStatus does. + internal UnityCliLoopCancelRequestResult CancelRunningTool() + { + return UnityCliLoopCancelRequestResult.From(_executionSession.CancelRunning()); + } + // Why convert here: Infrastructure cannot see the Domain snapshot type, so the phase leaves // as its wire name, the same way the busy exception below carries it. internal UnityCliLoopExecutionStatus GetExecutionStatus() diff --git a/Packages/src/Editor/Application/UnityCliLoopToolRegistrar.cs b/Packages/src/Editor/Application/UnityCliLoopToolRegistrar.cs index 8a9fddf66b..1390a379b8 100644 --- a/Packages/src/Editor/Application/UnityCliLoopToolRegistrar.cs +++ b/Packages/src/Editor/Application/UnityCliLoopToolRegistrar.cs @@ -100,9 +100,38 @@ public UnityCliLoopToolRegistry TryGetRegistry() return _sharedRegistry; } + // Why not cancellable by uloop cancel: this entry point serves callers inside the Editor, + // which manage cancellation with their own token, and uloop cancel only stops CLI requests. public Task ExecuteToolAsync(string toolName, JToken paramsToken, CancellationToken ct) { - return _toolExecutionService.ExecuteToolAsync(SharedRegistry, toolName, paramsToken, ct); + return _toolExecutionService.ExecuteToolAsync( + SharedRegistry, + toolName, + paramsToken, + ct, + cancellableByUser: false); + } + + /// + /// Runs a tool a CLI requested. cancellableByUser tells whether uloop cancel may stop it. + /// + internal Task ExecuteRequestedToolAsync( + string toolName, + JToken paramsToken, + CancellationToken ct, + bool cancellableByUser) + { + return _toolExecutionService.ExecuteToolAsync( + SharedRegistry, + toolName, + paramsToken, + ct, + cancellableByUser); + } + + internal UnityCliLoopCancelRequestResult CancelRunningTool() + { + return _toolExecutionService.CancelRunningTool(); } internal UnityCliLoopExecutionStatus GetExecutionStatus() From 0a323f41bb0fae57f9df8b646d71ee5185299a59 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:36:49 +0900 Subject: [PATCH 3/9] feat: Add the cancel-running-tool bridge command and the tool_cancelled error cancel-running-tool fires the cancellation of the tool holding the execution slot and answers with Outcome Cancelled, NothingRunning, or NotCancellable. Like get-editor-status, the router answers it before the main-thread switch and outside the activity hold, because it exists for the tool that blocks the main thread. When it cancels something it also asks for the pause point's Editor pause to be released, as a client disconnect does. NotCancellable reports Success false so the CLI exits 1. JsonRpcRequestProcessor now evaluates the client-disconnect cancellation policy for every request, not only for dispatch-ack ones, and passes it down as cancellableByUser, so uloop cancel never stops a waiting compile or a PlayMode run-tests that respects the Enter Play Mode settings. A tool stopped by uloop cancel is answered with a tool_cancelled error naming the tool and is not logged as an error. Its error.data shape is frozen in a contract fixture shared with the Go CLI. --- .../Editor/JsonRpcRequestProcessorTests.cs | 133 +++++++++++++++++- Assets/Tests/Editor/PausePointTests.cs | 58 ++++++++ .../Editor/ToolCancelledErrorContractTests.cs | 46 ++++++ .../ToolCancelledErrorContractTests.cs.meta | 11 ++ ...nityCliLoopExecutionRouterActivityTests.cs | 21 ++- .../Editor/UnityCliLoopToolRegistryTests.cs | 29 ++++ .../UnityCliLoopEditorBootstrapper.cs | 9 +- .../Api/CancelRunningToolBridgeCommand.cs | 36 +++++ .../CancelRunningToolBridgeCommand.cs.meta | 11 ++ .../Api/CancelRunningToolResponse.cs | 30 ++++ .../Api/CancelRunningToolResponse.cs.meta | 11 ++ .../Infrastructure/Api/JsonRpcErrorTypes.cs | 1 + .../Api/JsonRpcRequestProcessor.cs | 19 ++- .../Api/JsonRpcResponseFactory.cs | 6 + .../Api/ToolCancelledErrorData.cs | 19 +++ .../Api/ToolCancelledErrorData.cs.meta | 11 ++ .../Api/UnityCliLoopExecutionRouter.cs | 28 +++- .../ToolContracts/UnityCliLoopConstants.cs | 1 + .../tool_cancelled_error_contract.json | 8 ++ 19 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 Assets/Tests/Editor/ToolCancelledErrorContractTests.cs create mode 100644 Assets/Tests/Editor/ToolCancelledErrorContractTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs create mode 100644 Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs create mode 100644 Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs create mode 100644 Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs.meta create mode 100644 tests/contracts/tool_cancelled_error_contract.json diff --git a/Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs b/Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs index d366e89170..9c0819f4f6 100644 --- a/Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs +++ b/Assets/Tests/Editor/JsonRpcRequestProcessorTests.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using NUnit.Framework; using io.github.hatayama.UnityCliLoop.Application; @@ -11,7 +12,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { /// - /// Verifies JSON-RPC notification handling in JsonRpcRequestProcessor without executing tools. + /// Verifies JSON-RPC notification handling in JsonRpcRequestProcessor, and how it tells the + /// execution path whether uloop cancel may stop a request. /// public sealed class JsonRpcRequestProcessorTests { @@ -30,6 +32,135 @@ public void ProcessRequest_WhenRequestIsNotification_ReturnsNoResponse(string no Assert.That(responseTask.GetAwaiter().GetResult(), Is.Null); } + /// + /// Verifies uloop cancel may stop exactly the requests a client disconnect would cancel: + /// compile only when it does not wait for the domain reload, run-tests unless it runs in + /// PlayMode respecting the Enter Play Mode settings, with or without a dispatch ack. + /// + [TestCase("compile", "{}", false, "NotCancellable")] + [TestCase("compile", "{\"WaitForDomainReload\":true}", false, "NotCancellable")] + [TestCase("compile", "{\"WaitForDomainReload\":false}", false, "Cancelled")] + [TestCase("compile", "{\"WaitForDomainReload\":false}", true, "Cancelled")] + [TestCase("compile", "{}", true, "NotCancellable")] + [TestCase("run-tests", "{\"TestMode\":\"PlayMode\",\"RespectEnterPlayModeSettings\":true}", false, "NotCancellable")] + [TestCase("run-tests", "{\"TestMode\":\"PlayMode\",\"RespectEnterPlayModeSettings\":true}", true, "NotCancellable")] + [TestCase("run-tests", "{\"TestMode\":\"EditMode\"}", false, "Cancelled")] + [TestCase("run-tests", "{\"TestMode\":\"EditMode\"}", true, "Cancelled")] + public async Task ProcessRequest_WhileToolRuns_CancelOutcomeFollowsClientDisconnectPolicy( + string toolName, + string paramsJson, + bool acceptsDispatchAck, + string expectedOutcome) + { + PendingCancellableTool tool = new PendingCancellableTool(toolName); + UnityCliLoopToolRegistrarService registrarService = CreateRegistrarService(tool); + JsonRpcRequestProcessor processor = CreateProcessor(registrarService); + string requestJson = BuildToolRequest(toolName, paramsJson, acceptsDispatchAck); + + Task responseTask = processor.ProcessRequestWithEarlyResponseAsync( + requestJson, + CancellationToken.None, + (responseJson, cancelOnClientDisconnect, createHeartbeatJson) => Task.CompletedTask); + Assert.That(responseTask.IsCompleted, Is.False); + + UnityCliLoopCancelRequestResult cancelResult = registrarService.CancelRunningTool(); + // Why complete the tool either way: a NotCancellable request keeps running and the test + // must not leave it pending. + tool.Complete(); + await AwaitWithTimeout(responseTask); + + Assert.That(cancelResult.Outcome, Is.EqualTo(expectedOutcome)); + } + + /// + /// Verifies a request stopped by uloop cancel is answered with a tool_cancelled error that + /// names the tool, and that the expected cancel is not logged as an error. + /// + [Test] + public async Task ProcessRequest_WhenToolIsCancelledByUser_AnswersToolCancelledErrorWithoutLogging() + { + PendingCancellableTool tool = new PendingCancellableTool("cancel-target-tool"); + UnityCliLoopToolRegistrarService registrarService = CreateRegistrarService(tool); + JsonRpcRequestProcessor processor = CreateProcessor(registrarService); + + Task responseTask = processor.ProcessRequest( + BuildToolRequest("cancel-target-tool", "{}", false), + CancellationToken.None); + registrarService.CancelRunningTool(); + await AwaitWithTimeout(responseTask); + + JObject error = (JObject)JObject.Parse(await responseTask)["error"]; + Assert.That(error, Is.Not.Null); + Assert.That(error.Value("message"), Is.EqualTo("'cancel-target-tool' was cancelled by uloop cancel.")); + JObject data = (JObject)error["data"]; + Assert.That(data.Value("type"), Is.EqualTo("tool_cancelled")); + Assert.That(data.Value("runningToolName"), Is.EqualTo("cancel-target-tool")); + Assert.That(data.Value("message"), Is.EqualTo("'cancel-target-tool' was cancelled by uloop cancel.")); + UnityEngine.TestTools.LogAssert.NoUnexpectedReceived(); + } + + private static async Task AwaitWithTimeout(Task task) + { + Task completedTask = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(1))); + Assert.That(completedTask, Is.SameAs(task), "The request did not finish before the test timeout."); + } + + private static string BuildToolRequest(string toolName, string paramsJson, bool acceptsDispatchAck) + { + string dispatchAck = acceptsDispatchAck ? ",\"acceptsDispatchAck\":true" : string.Empty; + return + "{\"jsonrpc\":\"2.0\",\"method\":\"" + toolName + + "\",\"params\":" + paramsJson + + ",\"id\":1,\"uloop\":{\"protocolVersion\":" + CliConstants.REQUIRED_CLI_PROTOCOL_VERSION + + dispatchAck + "}}"; + } + + private static UnityCliLoopToolRegistrarService CreateRegistrarService(IUnityCliLoopTool tool) + { + return new UnityCliLoopToolRegistrarService( + new EmptyInternalToolNameProvider(), + new AllToolsEnabledSettingsPort(), + new UnityCliLoopToolExecutionService(new IdleEditorRuntimeStatePort(), new ToolExecutionSession()), + () => new[] { tool }); + } + + private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService registrarService) + { + return new JsonRpcRequestProcessor(new UnityCliLoopExecutionRouter(registrarService, new EditorExecutionActivity(new InertProcessActivityApi()))); + } + + // Stays pending until its token fires or the test completes it, so the test can cancel it + // while it holds the execution slot. + private sealed class PendingCancellableTool : IUnityCliLoopTool + { + private readonly TaskCompletionSource _completion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public PendingCancellableTool(string toolName) + { + ToolName = toolName; + } + + public string ToolName { get; } + + public ToolParameterSchema ParameterSchema => new(); + + public Task ExecuteAsync(JToken paramsToken, CancellationToken ct) + { + ct.Register(() => _completion.TrySetCanceled(ct)); + return _completion.Task; + } + + public void Complete() + { + _completion.TrySetResult(new PendingCancellableResponse()); + } + } + + private sealed class PendingCancellableResponse : UnityCliLoopToolResponse + { + } + private static JsonRpcRequestProcessor CreateProcessor() { UnityCliLoopToolRegistrarService registrarService = new UnityCliLoopToolRegistrarService( diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index ac5dfe813e..2d6e60c255 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -13,6 +13,7 @@ using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.CompositionRoot; +using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Infrastructure; using io.github.hatayama.UnityCliLoop.Runtime; @@ -1192,6 +1193,63 @@ public void ResumeEditorPauseForClientDisconnect_WhenPaused_ShouldResumeOnMainTh Assert.That(_pauseController.ResumeCount, Is.EqualTo(1)); } + /// + /// Verifies uloop cancel that stops a running tool also releases a pause point's Editor + /// pause on the next main-thread apply, as a client disconnect does. + /// + [Test] + public void CancelRunningToolBridgeCommand_WhenToolIsCancelled_ShouldResumePauseOnMainThreadApply() + { + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePoint.Pause("jump"); + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease lease = session.TryEnter("run-tests").Lease; + + CancelRunningToolResponse response = CancelRunningToolBridgeCommand.Execute(CreateRegistrarService(session)); + Assert.That(_pauseController.ResumeCount, Is.EqualTo(0)); + UloopPausePointRegistry.ApplyPendingClientDisconnectResume(); + + Assert.That(response.Outcome, Is.EqualTo("Cancelled")); + Assert.That(response.Success, Is.True); + Assert.That(_pauseController.IsPaused, Is.False); + Assert.That(_pauseController.ResumeCount, Is.EqualTo(1)); + + lease.Dispose(); + } + + /// + /// Verifies uloop cancel that cannot stop the running tool leaves the pause point's Editor + /// pause alone and reports the refusal as a failure. + /// + [Test] + public void CancelRunningToolBridgeCommand_WhenToolIsNotCancellable_ShouldKeepPauseAndReportFailure() + { + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePoint.Pause("jump"); + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease lease = session.TryEnter("compile", cancellableByUser: false).Lease; + + CancelRunningToolResponse response = CancelRunningToolBridgeCommand.Execute(CreateRegistrarService(session)); + UloopPausePointRegistry.ApplyPendingClientDisconnectResume(); + + Assert.That(response.Outcome, Is.EqualTo("NotCancellable")); + Assert.That(response.RunningToolName, Is.EqualTo("compile")); + Assert.That(response.Success, Is.False); + Assert.That(_pauseController.IsPaused, Is.True); + Assert.That(_pauseController.ResumeCount, Is.EqualTo(0)); + + lease.Dispose(); + } + + private static UnityCliLoopToolRegistrarService CreateRegistrarService(ToolExecutionSession session) + { + return new UnityCliLoopToolRegistrarService( + new EmptyInternalToolNameProvider(), + new AlwaysEnabledToolSettingsPort(), + new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort(), session), + () => Array.Empty()); + } + [Test] public void ApplyPendingClientDisconnectResume_WhenNotPaused_ShouldDiscardPendingFlag() { diff --git a/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs b/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs new file mode 100644 index 0000000000..b8d9ae307a --- /dev/null +++ b/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs @@ -0,0 +1,46 @@ +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Freezes the tool_cancelled JSON-RPC error.data frame that the Go CLI decodes into a typed struct. + /// + public sealed class ToolCancelledErrorContractTests + { + private const string SharedContractPath = "tests/contracts/tool_cancelled_error_contract.json"; + + /// + /// Verifies ToolCancelledErrorData emits exactly the error.data fields and values the shared fixture freezes. + /// + [Test] + public void ToolCancelledErrorData_WhenSerialized_MatchesSharedErrorData() + { + JObject expected = ReadSharedErrorData(); + ToolCancelledErrorData errorData = new ToolCancelledErrorData( + runningToolName: "run-tests", + message: "'run-tests' was cancelled by uloop cancel."); + + string json = JsonConvert.SerializeObject( + errorData, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings); + JObject actual = JObject.Parse(json); + + Assert.That(JToken.DeepEquals(actual, expected), Is.True, $"Expected {expected} but got {actual}"); + } + + private static JObject ReadSharedErrorData() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string json = File.ReadAllText(Path.Combine(projectRoot, SharedContractPath)); + JObject contract = JObject.Parse(json); + return (JObject)contract["errorData"]!; + } + } +} diff --git a/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs.meta b/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs.meta new file mode 100644 index 0000000000..eb33f0c99a --- /dev/null +++ b/Assets/Tests/Editor/ToolCancelledErrorContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 605f270d07bc64c79a34c264329a34ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs b/Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs index 4992e101d3..40b436aaef 100644 --- a/Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopExecutionRouterActivityTests.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { /// /// Verifies the execution router holds an operating-system activity for exactly the length of each - /// tool or internal bridge command, and never for the editor status answer. + /// tool or internal bridge command, and never for the editor status or cancel answers. /// public sealed class UnityCliLoopExecutionRouterActivityTests { @@ -131,6 +131,25 @@ public async Task ExecuteAsync_EditorStatus_DoesNotBeginAnActivity() Assert.That(api.BeginCount, Is.EqualTo(0)); } + /// + /// Verifies the cancel-running-tool answer starts no activity, because it must answer while a + /// tool blocks the Editor main thread. + /// + [Test] + public async Task ExecuteAsync_CancelRunningTool_DoesNotBeginAnActivity() + { + RecordingProcessActivityApi api = new RecordingProcessActivityApi(); + UnityCliLoopExecutionRouter router = CreateRouter(api); + + UnityCliLoopToolResponse response = await router.ExecuteAsync( + UnityCliLoopConstants.COMMAND_NAME_CANCEL_RUNNING_TOOL, + new JObject(), + CancellationToken.None); + + Assert.That(response, Is.InstanceOf()); + Assert.That(api.BeginCount, Is.EqualTo(0)); + } + /// /// Verifies an internal bridge command holds one activity and ends it when the command returns. /// diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index d6b0d3a171..8404e3e8d8 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -441,6 +441,35 @@ public async Task ExecuteCommandAsync_WhenCommandIsGetEditorStatus_ReturnsEditor } } + [Test] + public async Task ExecuteCommandAsync_WhenCommandIsCancelRunningTool_AnswersWithoutWaitingForMainThread() + { + // Tests that cancel-running-tool answers while the Editor main thread is held, because uloop + // cancel exists for the tool that blocks it. The queueing dispatcher holds every switch, as + // in the get-editor-status test above, so a router that switched first would leave the task + // unfinished. + UnityCliLoopExecutionRouter executionRouter = CreateExecutionRouter(); + MainThreadSwitcher.RegisterService(new QueueingDispatcher()); + + try + { + Task execution = executionRouter.ExecuteAsync( + UnityCliLoopConstants.COMMAND_NAME_CANCEL_RUNNING_TOOL, + new JObject(), + CancellationToken.None); + + Assert.That(execution.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + CancelRunningToolResponse response = await execution as CancelRunningToolResponse; + Assert.That(response, Is.Not.Null); + Assert.That(response.Outcome, Is.EqualTo("NothingRunning")); + Assert.That(response.Success, Is.True); + } + finally + { + EditorMainThreadDispatcherRestorer.Restore(); + } + } + [Test] public void Constructor_WhenLegacyDevelopmentToolsAreRemoved_DoesNotRegisterThem() { diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs index 619394bea4..b5619e0e1b 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs @@ -45,10 +45,11 @@ internal void Initialize() // Why last: a capture that throws stops nothing after it; Unity logs the exception and // hot reload captures again on the first update tick. Why inside this // InitializeOnLoadMethod and not afterAssemblyReload: the IPC listener opens in - // afterAssemblyReload, and every command except get-editor-status runs on the main - // thread's update or tick, so no command of this domain is answered before this - // returns — an edit made after `uloop compile` returns can no longer be captured as - // compiled source. When both the hot-reload and pause-point tools are disabled, the + // afterAssemblyReload, and every command except get-editor-status and + // cancel-running-tool runs on the main thread's update or tick (those two only read the + // execution slot or cancel what already holds it), so no command of this domain is + // answered before this returns — an edit made after `uloop compile` returns can no + // longer be captured as compiled source. When both the hot-reload and pause-point tools are disabled, the // capture is skipped here and runs on the first use of either tool instead. FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(isToolEnabled); } diff --git a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs new file mode 100644 index 0000000000..3251fd9107 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs @@ -0,0 +1,36 @@ +using System; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Runs the cancel-running-tool command behind uloop cancel: fires the cancellation of the tool + /// that holds the execution slot and reports what it found. + /// + internal static class CancelRunningToolBridgeCommand + { + // Why no main-thread switch: this must answer while a tool blocks the main thread. The + // cancellation has already fired, and the cancelled requests' continuations already ran, + // when CancelRunningTool returns, so the response is built from its result. + internal static CancelRunningToolResponse Execute(UnityCliLoopToolRegistrarService toolRegistrarService) + { + if (toolRegistrarService == null) + { + throw new ArgumentNullException(nameof(toolRegistrarService)); + } + + UnityCliLoopCancelRequestResult result = toolRegistrarService.CancelRunningTool(); + if (result.FiredCancellation) + { + // Why: a tool waiting for frames while a pause point holds Play Mode paused would + // never see its cancellation through. This only sets a flag; the next main-thread + // tick releases the pause. + UloopPausePointRegistry.ResumeEditorPauseForClientDisconnect(); + } + + return new CancelRunningToolResponse(result); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs.meta b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs.meta new file mode 100644 index 0000000000..9224b35035 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e55f2d4a14ac646f098d2c2d4baa1ce3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs new file mode 100644 index 0000000000..9c0382d8ed --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs @@ -0,0 +1,30 @@ +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Payload of the cancel-running-tool command behind uloop cancel. Outcome is Cancelled, + /// NothingRunning, or NotCancellable; the running-tool fields are null when nothing was running. + /// + public class CancelRunningToolResponse : UnityCliLoopToolResponse + { + internal CancelRunningToolResponse(UnityCliLoopCancelRequestResult result) + { + Outcome = result.Outcome; + RunningToolName = result.RunningToolName; + RunningToolElapsedSeconds = result.RunningToolElapsedSeconds; + // Why NotCancellable is a failure: the CLI's exit code follows Success, and the caller + // asked to stop a tool that is still running. + Success = result.Outcome != NotCancellableOutcome; + } + + private const string NotCancellableOutcome = "NotCancellable"; + + public string Outcome { get; } + + public string RunningToolName { get; } + + public int? RunningToolElapsedSeconds { get; } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs.meta new file mode 100644 index 0000000000..0a523603e7 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c335a215551e4009b730b89d4f7b208 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs index a9796d39f2..37baeefccb 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs @@ -9,5 +9,6 @@ public static class JsonRpcErrorTypes public const string InternalError = "internal_error"; public const string CliUpdateRequired = "cli_update_required"; public const string ServerBusy = "server_busy"; + public const string ToolCancelled = "tool_cancelled"; } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs index 86b0b091f2..bf6d6a0e5c 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs @@ -143,6 +143,9 @@ private async Task ProcessRpcRequest( request.ClientProtocolVersion); } + // Why computed for every request: the same policy decides whether uloop cancel may stop + // the request, and that holds whether or not the CLI asked for a dispatch ack. + bool cancellableByUser = ShouldCancelAcceptedRequestOnClientDisconnect(request); if (request.AcceptsDispatchAck && earlyResponseWriter != null) { int heartbeatIntervalSeconds = request.AcceptsHeartbeat @@ -155,14 +158,18 @@ private async Task ProcessRpcRequest( : null; await earlyResponseWriter( JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds), - ShouldCancelAcceptedRequestOnClientDisconnect(request), + cancellableByUser, createHeartbeatJson); } Stopwatch requestStopwatch = Stopwatch.StartNew(); Stopwatch executeMethodStopwatch = Stopwatch.StartNew(); - UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct); + UnityCliLoopToolResponse result = await ExecuteMethod( + request.Method, + request.Params, + ct, + cancellableByUser); executeMethodStopwatch.Stop(); JsonRpcResponseFactory.AppendTimingIfRequested( @@ -194,7 +201,8 @@ await earlyResponseWriter( private static void LogRpcExceptionIfNeeded(Exception ex) { - if (ex is UnityCliLoopToolBusyException) + // Why not log these: busy and uloop cancel are expected outcomes the CLI reports itself. + if (ex is UnityCliLoopToolBusyException || ex is UnityCliLoopToolCancelledException) { return; } @@ -239,9 +247,10 @@ private static void LogUnityCliLoopToolParameterValidationException(UnityCliLoop private async Task ExecuteMethod( string method, JToken paramsToken, - CancellationToken ct) + CancellationToken ct, + bool cancellableByUser) { - return await _executionRouter.ExecuteAsync(method, paramsToken, ct); + return await _executionRouter.ExecuteAsync(method, paramsToken, ct, cancellableByUser); } } } diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs index c4de865cb6..3b32203f35 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs @@ -84,6 +84,12 @@ internal static string CreateErrorResponse(object id, Exception ex) busyEx.RunningToolElapsedSeconds, busyEx.RunningToolPhase); } + else if (ex is UnityCliLoopToolCancelledException cancelledEx) + { + errorData = new ToolCancelledErrorData( + cancelledEx.ToolName, + exceptionResponse.Explanation ?? ex.Message); + } else { errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message); diff --git a/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs b/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs new file mode 100644 index 0000000000..76fea3107c --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs @@ -0,0 +1,19 @@ +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Tells the CLI that waited for a tool that uloop cancel stopped it, so the CLI can report the + /// cancel instead of a dropped connection. + /// + public class ToolCancelledErrorData : JsonRpcErrorData + { + public override string type => JsonRpcErrorTypes.ToolCancelled; + + public string runningToolName { get; } + + public ToolCancelledErrorData(string runningToolName, string message) + : base(message) + { + this.runningToolName = runningToolName; + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs.meta new file mode 100644 index 0000000000..2d13ea6143 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 713c6ee03fbf04a1a9bdd81115e9e6f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs b/Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs index 5402310054..fbd01a160b 100644 --- a/Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/UnityCliLoopExecutionRouter.cs @@ -14,6 +14,8 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// /// Routes JSON-RPC execution requests received by JsonRpcRequestProcessor to either the /// internal bridge command router or the registered tools in the Application layer. + /// get-editor-status and cancel-running-tool are answered here, off the main thread and outside + /// the activity hold, because they must answer while a tool blocks the Editor main thread. /// Terminology for "tool" vs "internal bridge command" is defined in docs/glossary.md. /// internal sealed class UnityCliLoopExecutionRouter @@ -39,11 +41,13 @@ internal UnityCliLoopExecutionRouter( /// /// JSON-RPC method name /// Parameters + /// Whether uloop cancel may stop the tool this request runs /// Execution result public async Task ExecuteAsync( string methodName, JToken paramsToken, - CancellationToken ct) + CancellationToken ct, + bool cancellableByUser = true) { if (methodName == UnityCliLoopConstants.COMMAND_NAME_GET_EDITOR_STATUS) { @@ -59,13 +63,21 @@ public async Task ExecuteAsync( EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick()); } + if (methodName == UnityCliLoopConstants.COMMAND_NAME_CANCEL_RUNNING_TOOL) + { + // Why before the main-thread switch below: uloop cancel exists for the tool that + // blocks the main thread, so it must answer without waiting for it. + ct.ThrowIfCancellationRequested(); + return CancelRunningToolBridgeCommand.Execute(_toolRegistrarService); + } + // Why hold an activity for the length of the request: macOS throttles an Editor that is not // frontmost, and a request that arrives while it is throttled is served several times slower. - // Why not around the status answer above: that path must stay free of anything that can block - // or fail, because it answers while the main thread is stuck. + // Why not around the status and cancel answers above: those paths must stay free of anything + // that can block or fail, because they answer while the main thread is stuck. using (_executionActivity.Hold()) { - return await ExecuteCommandOrToolAsync(methodName, paramsToken, ct); + return await ExecuteCommandOrToolAsync(methodName, paramsToken, ct, cancellableByUser); } } @@ -75,7 +87,8 @@ public async Task ExecuteAsync( private async Task ExecuteCommandOrToolAsync( string methodName, JToken paramsToken, - CancellationToken ct) + CancellationToken ct, + bool cancellableByUser) { UnityCliLoopToolResponse response; if (InternalBridgeCommandRouter.IsInternalCommand(methodName)) @@ -86,10 +99,11 @@ private async Task ExecuteCommandOrToolAsync( return response; } - response = await _toolRegistrarService.ExecuteToolAsync( + response = await _toolRegistrarService.ExecuteRequestedToolAsync( methodName, paramsToken, - ct); + ct, + cancellableByUser); return response; } } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 740629b445..3cf254040d 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -81,6 +81,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; public const string COMMAND_NAME_GET_RUN_TESTS_STATUS = "get-run-tests-status"; public const string COMMAND_NAME_GET_EDITOR_STATUS = "get-editor-status"; + public const string COMMAND_NAME_CANCEL_RUNNING_TOOL = "cancel-running-tool"; public const string COMMAND_NAME_AWAIT_PAUSE_POINT = "await-pause-point"; public const string SETTINGS_TOOL_NAME_PAUSE_POINT = "pause-point"; public const string COMMAND_NAME_PAUSE_POINT_STATUS = "pause-point-status"; diff --git a/tests/contracts/tool_cancelled_error_contract.json b/tests/contracts/tool_cancelled_error_contract.json new file mode 100644 index 0000000000..d1444b9dc6 --- /dev/null +++ b/tests/contracts/tool_cancelled_error_contract.json @@ -0,0 +1,8 @@ +{ + "comment": "Representative tool_cancelled error.data payload with every ToolCancelledErrorData field present. Go and C# contract tests decode this same frame.", + "errorData": { + "type": "tool_cancelled", + "message": "'run-tests' was cancelled by uloop cancel.", + "runningToolName": "run-tests" + } +} From b2e4c08577b9b4a375a6d26187c7dd86b3c00003 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:39:46 +0900 Subject: [PATCH 4/9] feat: Add uloop cancel to the project runner uloop cancel sends cancel-running-tool to the Editor once, with a 5-second timeout and no retry, because a second send could cancel a tool that started after the first. It prints the Editor's answer and exits 0 for Cancelled and NothingRunning and 1 for NotCancellable, following the answer's Success. A send that fails, including the generic internal error of a package that predates the command, goes through the ordinary error classification and exits 1. The command takes no options. --- cli/common/clicore/command_errors_test.go | 2 +- cli/common/clicore/command_registry.go | 2 + cli/common/clicore/command_registry_test.go | 2 + cli/dispatcher/shared-inputs-stamp.json | 2 +- .../internal/projectrunner/cancel_command.go | 73 ++++++++++++ .../projectrunner/cancel_command_test.go | 110 ++++++++++++++++++ .../internal/projectrunner/list_names_test.go | 4 +- .../projectrunner/native_command_help_test.go | 24 ++++ .../internal/projectrunner/runner_commands.go | 2 + .../projectrunner/runner_commands_test.go | 3 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 11 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 cli/project-runner/internal/projectrunner/cancel_command.go create mode 100644 cli/project-runner/internal/projectrunner/cancel_command_test.go diff --git a/cli/common/clicore/command_errors_test.go b/cli/common/clicore/command_errors_test.go index 356b977e26..1f107b1786 100644 --- a/cli/common/clicore/command_errors_test.go +++ b/cli/common/clicore/command_errors_test.go @@ -9,7 +9,7 @@ import ( func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { // Verifies unknown-command suggestions include visible built-in CLI commands before cached tools. names := availableCommandNames(ToolsCache{}) - expectedBuiltIns := []string{"launch", "verify-project", "list", "sync", "focus-window", "await-pause-point", "pause-point-status", "set-code-optimization", "status", "skills", "package", "install", "update", "uninstall"} + expectedBuiltIns := []string{"launch", "verify-project", "list", "sync", "focus-window", "await-pause-point", "pause-point-status", "set-code-optimization", "status", "cancel", "skills", "package", "install", "update", "uninstall"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/cli/common/clicore/command_registry.go b/cli/common/clicore/command_registry.go index 8e0e8e1493..96cb861887 100644 --- a/cli/common/clicore/command_registry.go +++ b/cli/common/clicore/command_registry.go @@ -16,6 +16,7 @@ const ( PausePointStatusUserCommandName = "pause-point-status" SetCodeOptimizationCommandName = "set-code-optimization" StatusCommandName = "status" + CancelCommandName = "cancel" RunTestsCommandName = "run-tests" ) @@ -47,6 +48,7 @@ var NativeCommands = []NativeCommandEntry{ {Name: PausePointStatusUserCommandName, Description: "Show one pause point marker's state, or list all registered markers when no target is given", Owner: RunnerOwned}, {Name: SetCodeOptimizationCommandName, Description: "Switch Unity's Code Optimization to Debug for this session or, with approval, as the machine-wide startup default", Owner: RunnerOwned}, {Name: StatusCommandName, Description: "Report whether the Unity Editor for this project can take a command now, without changing anything", Owner: RunnerOwned}, + {Name: CancelCommandName, Description: "Cancel the tool the Unity Editor for this project is running now", Owner: RunnerOwned}, {Name: SkillsCommandName, Description: "List, install, or uninstall agent skills", Owner: DispatcherOwned}, {Name: PackageCommandName, Description: "Install or inspect the Unity CLI Loop package in a project", Owner: DispatcherOwned}, {Name: InstallCommandName, Description: "Configure the global uloop dispatcher binary", Owner: DispatcherOwned}, diff --git a/cli/common/clicore/command_registry_test.go b/cli/common/clicore/command_registry_test.go index a27c4fc3b0..012cd3097d 100644 --- a/cli/common/clicore/command_registry_test.go +++ b/cli/common/clicore/command_registry_test.go @@ -21,6 +21,7 @@ func TestNativeCommandEntriesDeclareOwners(t *testing.T) { PausePointStatusUserCommandName: RunnerOwned, SetCodeOptimizationCommandName: RunnerOwned, StatusCommandName: RunnerOwned, + CancelCommandName: RunnerOwned, } if len(NativeCommands) != len(expectedOwners) { t.Fatalf("native command owner fixture is stale: %#v", NativeCommands) @@ -76,6 +77,7 @@ func TestIsRunnerOwnedCommandName(t *testing.T) { PausePointStatusUserCommandName, SetCodeOptimizationCommandName, StatusCommandName, + CancelCommandName, } { if !IsRunnerOwnedCommandName(command) { t.Fatalf("%s must be runner-owned", command) diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 4a8957d275..2a5d2cf273 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "3124e4eb0925cead5bb5449d3219778f38fbd48e" + "sharedInputsHash": "5e9f85506e39907bc1a3010559e2eacef32e8a57" } diff --git a/cli/project-runner/internal/projectrunner/cancel_command.go b/cli/project-runner/internal/projectrunner/cancel_command.go new file mode 100644 index 0000000000..54de45b27b --- /dev/null +++ b/cli/project-runner/internal/projectrunner/cancel_command.go @@ -0,0 +1,73 @@ +package projectrunner + +import ( + "context" + "encoding/json" + "io" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + + "github.com/hatayama/unity-cli-loop/common/clicontract" + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const cancelRunningToolBridgeCommandName = "cancel-running-tool" + +// Long enough for an Editor that answers off its main thread, which cancel-running-tool always +// does, and short enough that a caller stuck behind BUSY learns quickly that cancel did not land. +const cancelCommandTimeout = 5 * time.Second + +func runCancelCommand( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, +) int { + return runCancelCommandWithTimeout(ctx, connection, args, stdout, stderr, cancelCommandTimeout) +} + +// runCancelCommandWithTimeout sends cancel-running-tool once and prints the Editor's answer. +// It exits 0 for Cancelled and NothingRunning and 1 for NotCancellable, following the answer's +// Success. It never retries: a second send could cancel a tool that started after the first. +func runCancelCommandWithTimeout( + ctx context.Context, + connection unityipc.Connection, + args []string, + stdout io.Writer, + stderr io.Writer, + timeout time.Duration, +) int { + errorContext := clierrors.ErrorContext{ProjectRoot: connection.ProjectRoot, Command: clicore.CancelCommandName} + if len(args) > 0 { + clierrors.WriteClassifiedError(stderr, &clierrors.ArgumentError{ + Message: "cancel takes no options: " + args[0], + Option: args[0], + NextActions: []string{"Run `uloop cancel` with no options other than --project-path."}, + }, errorContext) + return 1 + } + + result, err := sendCancelRunningTool(ctx, connection, timeout) + if err != nil { + clierrors.WriteClassifiedError(stderr, err, errorContext) + return 1 + } + clicore.WriteJSON(stdout, result) + return toolEnvelopeExitCode(result) +} + +func sendCancelRunningTool( + ctx context.Context, + connection unityipc.Connection, + timeout time.Duration, +) (json.RawMessage, error) { + sendCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return unityipc.NewClient(connection, clicontract.ProjectRunnerVersion()).Send( + sendCtx, + cancelRunningToolBridgeCommandName, + map[string]any{}) +} diff --git a/cli/project-runner/internal/projectrunner/cancel_command_test.go b/cli/project-runner/internal/projectrunner/cancel_command_test.go new file mode 100644 index 0000000000..cd9664ebfc --- /dev/null +++ b/cli/project-runner/internal/projectrunner/cancel_command_test.go @@ -0,0 +1,110 @@ +package projectrunner + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +type cancelRun struct { + code int + stdout string + stderr string +} + +func runCancelForTest(connection unityipc.Connection, args []string) cancelRun { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runCancelCommandWithTimeout(context.Background(), connection, args, &stdout, &stderr, 2*time.Second) + return cancelRun{code: code, stdout: stdout.String(), stderr: stderr.String()} +} + +// Verifies each answer the Editor gives to cancel-running-tool is printed as it came and exits +// with the code its Success implies: Cancelled and NothingRunning exit 0, NotCancellable exits 1. +func TestRunCancelCommandPrintsOutcomeAndExitsBySuccess(t *testing.T) { + cases := []struct { + name string + result string + wantCode int + }{ + { + name: "Cancelled", + result: `{"Outcome":"Cancelled","RunningToolName":"execute-dynamic-code","RunningToolElapsedSeconds":12,"Success":true}`, + wantCode: 0, + }, + { + name: "NothingRunning", + result: `{"Outcome":"NothingRunning","RunningToolName":null,"RunningToolElapsedSeconds":null,"Success":true}`, + wantCode: 0, + }, + { + name: "NotCancellable", + result: `{"Outcome":"NotCancellable","RunningToolName":"compile","RunningToolElapsedSeconds":3,"Success":false}`, + wantCode: 1, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := writeFakeUnityProject(t) + server := startFakeUnityResultServer(t, projectRoot, cancelRunningToolBridgeCommandName, testCase.result) + + run := runCancelForTest(server.connection, nil) + + if run.code != testCase.wantCode { + t.Fatalf("exit code = %d, want %d; stdout=%s stderr=%s", run.code, testCase.wantCode, run.stdout, run.stderr) + } + if !strings.Contains(run.stdout, `"Outcome": "`+testCase.name+`"`) { + t.Fatalf("stdout must carry the Editor's outcome:\n%s", run.stdout) + } + if run.stderr != "" { + t.Fatalf("stderr must stay empty when the Editor answered: %s", run.stderr) + } + server.receivedRequest(t) + assertServerDidNotFail(t, server.serverErr) + }) + } +} + +// Verifies a cancel that cannot reach the Editor prints a classified connection error on stderr +// and exits 1 without printing an answer. +func TestRunCancelCommandReportsUnreachableEditor(t *testing.T) { + projectRoot := writeFakeUnityProject(t) + + run := runCancelForTest(unreachableConnection(projectRoot), nil) + + if run.code != 1 { + t.Fatalf("exit code = %d, want 1", run.code) + } + if run.stdout != "" { + t.Fatalf("stdout must stay empty on a send error: %s", run.stdout) + } + if !strings.Contains(run.stderr, `"ErrorCode": "UNITY_NOT_REACHABLE"`) || !strings.Contains(run.stderr, `"Command": "cancel"`) { + t.Fatalf("stderr must be a classified connection error for cancel:\n%s", run.stderr) + } +} + +// Verifies an Editor whose package predates cancel-running-tool, which answers with its generic +// internal error, is reported through the ordinary RPC error classification and exits 1. +func TestRunCancelCommandReportsPackageWithoutCancelCommand(t *testing.T) { + projectRoot := writeFakeUnityProject(t) + server := startFakeUnityServer(t, projectRoot, cancelRunningToolBridgeCommandName, + `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Internal error","data":{"type":"internal_error","message":"Unknown tool: cancel-running-tool"}}}`) + + run := runCancelForTest(server.connection, nil) + + if run.code != 1 { + t.Fatalf("exit code = %d, want 1", run.code) + } + if run.stdout != "" { + t.Fatalf("stdout must stay empty on an RPC error: %s", run.stdout) + } + if !strings.Contains(run.stderr, `"ErrorCode": "UNITY_RPC_ERROR"`) || + !strings.Contains(run.stderr, "Unknown tool: cancel-running-tool") { + t.Fatalf("stderr must be the classified RPC error naming the unknown command:\n%s", run.stderr) + } + server.receivedRequest(t) +} diff --git a/cli/project-runner/internal/projectrunner/list_names_test.go b/cli/project-runner/internal/projectrunner/list_names_test.go index 633999331a..1161f38f8c 100644 --- a/cli/project-runner/internal/projectrunner/list_names_test.go +++ b/cli/project-runner/internal/projectrunner/list_names_test.go @@ -44,7 +44,7 @@ func TestRunResolvedProjectCommandListNamesWritesNativeAndLiveToolNames(t *testi t.Fatalf("list --names failed: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) } - const expected = "launch\nverify-project\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nstatus\nskills\npackage\ninstall\nupdate\nuninstall\nversion\nenable-watch\nclear-watch\nget-watch-values\n" + const expected = "launch\nverify-project\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nstatus\ncancel\nskills\npackage\ninstall\nupdate\nuninstall\nversion\nenable-watch\nclear-watch\nget-watch-values\n" if stdout.String() != expected { t.Fatalf("list --names output mismatch:\n got:\n%s\nwant:\n%s", stdout.String(), expected) } @@ -58,7 +58,7 @@ func TestRunResolvedProjectCommandListNamesWritesNativeAndLiveToolNames(t *testi // Verifies list accepts repeated names selectors but rejects value assignment and positional // arguments with the complete stable INVALID_ARGUMENT envelope. func TestRunListOptionBoundaries(t *testing.T) { - const namesOutput = "launch\nverify-project\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nstatus\nskills\npackage\ninstall\nupdate\nuninstall\nversion\n" + const namesOutput = "launch\nverify-project\nlist\nsync\nfocus-window\nawait-pause-point\npause-point-status\nset-code-optimization\nstatus\ncancel\nskills\npackage\ninstall\nupdate\nuninstall\nversion\n" const namesAssignmentError = "{\n" + " \"Success\": false,\n" + " \"Error\": {\n" + diff --git a/cli/project-runner/internal/projectrunner/native_command_help_test.go b/cli/project-runner/internal/projectrunner/native_command_help_test.go index 9a1bfcbe78..89506ba8ea 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help_test.go +++ b/cli/project-runner/internal/projectrunner/native_command_help_test.go @@ -140,6 +140,30 @@ func TestStatusHelpPrintsUsage(t *testing.T) { } } +// Verifies cancel --help prints its usage line and registry description with only the global +// option, since cancel takes no options of its own. +func TestCancelHelpPrintsUsage(t *testing.T) { + t.Chdir(t.TempDir()) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"cancel", "--help"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("cancel --help failed: code=%d stderr=%s", code, stderr.String()) + } + const expected = "Usage:\n" + + " uloop cancel\n" + + "\n" + + "Cancel the tool the Unity Editor for this project is running now\n" + + "\n" + + "Global options:\n" + + " --project-path Run against a Unity project outside the current directory\n" + if stdout.String() != expected { + t.Fatalf("cancel --help output mismatch:\n got:\n%s\nwant:\n%s", stdout.String(), expected) + } +} + // Verifies list --help prints the complete names-only option contract, including the usage and // global options sections that callers need after the unknown-option recovery guidance. func TestRunProjectLocalListHelpOutput(t *testing.T) { diff --git a/cli/project-runner/internal/projectrunner/runner_commands.go b/cli/project-runner/internal/projectrunner/runner_commands.go index 168473defc..0485ee0ce9 100644 --- a/cli/project-runner/internal/projectrunner/runner_commands.go +++ b/cli/project-runner/internal/projectrunner/runner_commands.go @@ -40,6 +40,8 @@ func runResolvedProjectCommand( return runSetCodeOptimizationCommand(ctx, connection, commandArgs, stdout, stderr) case clicore.StatusCommandName: return runStatusCommand(ctx, connection, commandArgs, stdout, stderr) + case clicore.CancelCommandName: + return runCancelCommand(ctx, connection, commandArgs, stdout, stderr) case pausePointEnableCommandName: return runEnablePausePointCommand(ctx, connection, commandArgs, startPath, stdout, stderr) default: diff --git a/cli/project-runner/internal/projectrunner/runner_commands_test.go b/cli/project-runner/internal/projectrunner/runner_commands_test.go index 8c45edada9..187e2480ec 100644 --- a/cli/project-runner/internal/projectrunner/runner_commands_test.go +++ b/cli/project-runner/internal/projectrunner/runner_commands_test.go @@ -130,7 +130,7 @@ func writeFakeUnityProject(t *testing.T) string { // Verifies that native commands with their own parsers reject an unknown flag themselves, // naming the command, instead of falling through to the dynamic tool catalog. // Each input is one only the native parser rejects this way: the dynamic catalog path reports -// await-pause-point, pause-point-status, and status as unknown commands, and +// await-pause-point, pause-point-status, status, and cancel as unknown commands, and // enable-pause-point's --resume-play as an unknown option instead of requiring --await. func TestRunResolvedProjectCommandRoutesNativeCommandsToTheirOwnParsers(t *testing.T) { cases := []struct { @@ -142,6 +142,7 @@ func TestRunResolvedProjectCommandRoutesNativeCommandsToTheirOwnParsers(t *testi {command: clicore.PausePointStatusUserCommandName, args: []string{"--bogus-flag"}, wantMessage: `"Message": "--bogus-flag requires a value"`}, {command: pausePointEnableCommandName, args: []string{"--resume-play"}, wantMessage: "require --await"}, {command: clicore.StatusCommandName, args: []string{"--bogus-flag"}, wantMessage: `"Message": "status takes no options: --bogus-flag"`}, + {command: clicore.CancelCommandName, args: []string{"--foo"}, wantMessage: `"Message": "cancel takes no options: --foo"`}, } for _, testCase := range cases { t.Run(testCase.command, func(t *testing.T) { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index dae3a2906a..20e2e5c9d0 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "56650d472ba8362914f5bc6bfe91f7bffaab56f6" + "sharedInputsHash": "8734909b7884cd0bf8e85a9863b152296ae0a135" } From 7dbc5f381a30ca35d4897312c22d2bdef6a04bc6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:42:45 +0900 Subject: [PATCH 5/9] feat: Point BUSY and status at uloop cancel and classify tool_cancelled A tool_cancelled RPC error now classifies as TOOL_CANCELLED in the execution phase, not retryable, with the next step to confirm the Editor is Ready through uloop status. Before, the waiting CLI saw the dropped connection as a retryable UNITY_DISCONNECTED_AFTER_ACCEPT. The busy message ends with "Run `uloop cancel` to stop it." only when the busy came from the execution slot (it carries elapsed seconds) and the running tool is one uloop cancel can stop. An Editor-state busy such as unity-compile holds no slot and gets no hint, and the main-thread-stall wording is unchanged because cancelling cannot unblock the main thread. The paused Play Mode guidance replaces its Ctrl-C step with uloop cancel, and a Busy uloop status offers it under the same tool-name rule. ClientDisconnectCancelsRunningTool is exported so status can share it. --- cli/common/errors/busy_cancel_hint_test.go | 69 +++++++++++++++ cli/common/errors/busy_editor_state.go | 26 +++--- cli/common/errors/busy_editor_state_test.go | 8 +- cli/common/errors/busy_status.go | 15 +++- cli/common/errors/error_envelope.go | 1 + .../errors/error_envelope_classification.go | 2 + cli/common/errors/error_envelope_test.go | 2 +- cli/common/errors/rpc_error_data.go | 14 +++ cli/common/errors/tool_cancelled_error.go | 32 +++++++ .../tool_cancelled_error_contract_test.go | 85 +++++++++++++++++++ cli/dispatcher/shared-inputs-stamp.json | 2 +- .../internal/projectrunner/status_command.go | 12 +++ .../projectrunner/status_command_test.go | 33 +++++++ cli/project-runner/shared-inputs-stamp.json | 2 +- 14 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 cli/common/errors/busy_cancel_hint_test.go create mode 100644 cli/common/errors/tool_cancelled_error.go create mode 100644 cli/common/errors/tool_cancelled_error_contract_test.go diff --git a/cli/common/errors/busy_cancel_hint_test.go b/cli/common/errors/busy_cancel_hint_test.go new file mode 100644 index 0000000000..6d231785b2 --- /dev/null +++ b/cli/common/errors/busy_cancel_hint_test.go @@ -0,0 +1,69 @@ +package clierrors + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const busyCancelHintText = "Run `uloop cancel` to stop it." + +func classifyBusyForCancelHint(t *testing.T, data string) CLIError { + t.Helper() + err := &unityipc.RPCError{ + Code: -32603, + Message: "Unity is busy.", + Data: json.RawMessage(data), + } + return ClassifyError(err, ErrorContext{ProjectRoot: "/project", Command: "get-logs"}) +} + +// Verifies a busy from the execution slot held by a tool uloop cancel can stop ends its message +// with the cancel hint. +func TestBusyMessage_WhenCancellableToolHoldsSlot_EndsWithCancelHint(t *testing.T) { + cliErr := classifyBusyForCancelHint(t, + `{"type":"server_busy","runningToolName":"execute-dynamic-code","requestedToolName":"get-logs","runningToolElapsedSeconds":160}`) + + expected := "'get-logs' was not executed because Unity is busy running 'execute-dynamic-code' (running for 160s). uloop is single-flight per Editor. Wait for 'execute-dynamic-code' to complete, then run the command once more; do not retry in a loop. " + busyCancelHintText + if cliErr.Message != expected { + t.Fatalf("message mismatch:\n got: %s\nwant: %s", cliErr.Message, expected) + } +} + +// Verifies a busy without elapsed seconds gets no cancel hint even for a tool uloop cancel could +// stop, because only the execution slot reports elapsed seconds and only it has something to cancel. +func TestBusyMessage_WhenElapsedSecondsMissing_OmitsCancelHint(t *testing.T) { + cliErr := classifyBusyForCancelHint(t, + `{"type":"server_busy","runningToolName":"execute-dynamic-code","requestedToolName":"get-logs"}`) + + if strings.Contains(cliErr.Message, busyCancelHintText) { + t.Fatalf("busy without elapsed seconds must not suggest uloop cancel: %s", cliErr.Message) + } +} + +// Verifies a busy held by run-tests gets no cancel hint, because the payload cannot tell whether +// that run is one uloop cancel may stop. +func TestBusyMessage_WhenRunTestsHoldsSlot_OmitsCancelHint(t *testing.T) { + cliErr := classifyBusyForCancelHint(t, + `{"type":"server_busy","runningToolName":"run-tests","requestedToolName":"get-logs","runningToolElapsedSeconds":30}`) + + if !strings.Contains(cliErr.Message, "(running for 30s)") { + t.Fatalf("message must be the elapsed wording: %s", cliErr.Message) + } + if strings.Contains(cliErr.Message, busyCancelHintText) { + t.Fatalf("run-tests busy must not suggest uloop cancel: %s", cliErr.Message) + } +} + +// Verifies an Editor-state busy such as unity-compile, which holds no execution slot, gets no +// cancel hint. +func TestBusyMessage_WhenEditorStateIsCompiling_OmitsCancelHint(t *testing.T) { + cliErr := classifyBusyForCancelHint(t, + `{"type":"server_busy","runningToolName":"unity-compile","requestedToolName":"get-logs","isCompiling":true}`) + + if strings.Contains(cliErr.Message, busyCancelHintText) { + t.Fatalf("Editor-state busy must not suggest uloop cancel: %s", cliErr.Message) + } +} diff --git a/cli/common/errors/busy_editor_state.go b/cli/common/errors/busy_editor_state.go index 812943e7ea..7a03050e94 100644 --- a/cli/common/errors/busy_editor_state.go +++ b/cli/common/errors/busy_editor_state.go @@ -49,21 +49,20 @@ func unityServerBusyNextActions(data serverBusyErrorData) []string { // Why a paused Play Mode needs its own guidance: the default wait/retry pair is wrong // advice there. A running command that waits for a frame or a physics step never reaches // one while the Editor is paused, so it never completes and never releases the -// single-flight gate; the caller can wait forever. Why these recovery steps and no -// uloop subcommand: while the gate is held, clear-pause-point and control-play-mode are -// themselves rejected as busy, and await-pause-point reaches Unity but only reports the -// hit. Stopping the running command's own process is the only recovery that stays inside -// the CLI, because the bridge releases the Editor pause when that client disconnects. +// single-flight gate; the caller can wait forever. Why uloop cancel: while the gate is held, +// clear-pause-point and control-play-mode are themselves rejected as busy, and +// await-pause-point reaches Unity but only reports the hit. uloop cancel answers while the gate +// is held, cancels the running command, and releases the Editor pause. func pausedInPlayModeNextActions(runningToolName string) []string { actions := []string{ "Unity is paused in Play Mode. A running command that waits for a frame or a physics step cannot finish until play resumes, so this busy state does not clear on its own.", "Run `uloop pause-point-status` (it answers while Unity is busy) to see whether a pause-point hit is holding the pause.", } - if clientDisconnectCancelsRunningTool(runningToolName) { + if ClientDisconnectCancelsRunningTool(runningToolName) { actions = append( actions, - "Stop the uloop process that is running the command (Ctrl-C in its terminal, otherwise interrupt or kill that process). Its request is cancelled and returns no result, the Editor pause is released, and the next command can run.") + "Run `uloop cancel` (it answers while Unity is busy): the running command is cancelled and the Editor pause is released.") } return append( @@ -71,12 +70,13 @@ func pausedInPlayModeNextActions(runningToolName string) []string { "Release the pause in the Editor (Edit > Play Mode > Pause). Frames resume, so the running command finishes and returns its result.") } -// Why these two tool names are excluded: the Editor keeps a run-tests run that respects -// Enter Play Mode settings, and a compile that waits for a domain reload, alive across a -// client disconnect, so no disconnect monitor runs and stopping the process never releases -// the Editor pause. The busy payload carries only the tool name, not the options that decide -// it, so both names drop the step rather than promise a recovery that may not happen. -func clientDisconnectCancelsRunningTool(runningToolName string) bool { +// ClientDisconnectCancelsRunningTool reports whether the running tool is one a client +// disconnect, and so uloop cancel, can stop. Why these two tool names are excluded: the Editor +// keeps a run-tests run that respects Enter Play Mode settings, and a compile that waits for a +// domain reload, alive across a client disconnect and uloop cancel alike. The busy payload +// carries only the tool name, not the options that decide it, so both names drop the step +// rather than promise a recovery that may not happen. +func ClientDisconnectCancelsRunningTool(runningToolName string) bool { return runningToolName != "run-tests" && runningToolName != "compile" } diff --git a/cli/common/errors/busy_editor_state_test.go b/cli/common/errors/busy_editor_state_test.go index f28a667b4a..0d60063d72 100644 --- a/cli/common/errors/busy_editor_state_test.go +++ b/cli/common/errors/busy_editor_state_test.go @@ -92,7 +92,7 @@ func TestUnityServerBusyEditorActivitySummary_CopiesKnownFields(t *testing.T) { const ( expectedPausedBusyStalledAction = "Unity is paused in Play Mode. A running command that waits for a frame or a physics step cannot finish until play resumes, so this busy state does not clear on its own." expectedPausedBusyStatusAction = "Run `uloop pause-point-status` (it answers while Unity is busy) to see whether a pause-point hit is holding the pause." - expectedPausedBusyStopAction = "Stop the uloop process that is running the command (Ctrl-C in its terminal, otherwise interrupt or kill that process). Its request is cancelled and returns no result, the Editor pause is released, and the next command can run." + expectedPausedBusyStopAction = "Run `uloop cancel` (it answers while Unity is busy): the running command is cancelled and the Editor pause is released." expectedPausedBusyResumeAction = "Release the pause in the Editor (Edit > Play Mode > Pause). Frames resume, so the running command finishes and returns its result." ) @@ -138,8 +138,8 @@ func TestUnityServerBusyNextActions_WhenPausedOutsidePlayMode_OmitsPauseRecovery } } -// Verifies a running tool the Editor keeps alive across a client disconnect drops the -// stop-the-process step, because stopping it would not release the Editor pause. +// Verifies a running tool the Editor keeps alive across a client disconnect and uloop cancel +// drops the cancel step, because cancelling would neither stop it nor release the Editor pause. func TestUnityServerBusyNextActions_WhenPausedWhileRunningDisconnectSurvivingTool_OmitsStopStep(t *testing.T) { isPlaying := true isPaused := true @@ -149,7 +149,7 @@ func TestUnityServerBusyNextActions_WhenPausedWhileRunningDisconnectSurvivingToo actions := unityServerBusyNextActions(data) for _, action := range actions { if action == expectedPausedBusyStopAction { - t.Fatalf("%s must not promise recovery by stopping the process: %#v", runningToolName, actions) + t.Fatalf("%s must not promise recovery through uloop cancel: %#v", runningToolName, actions) } } if actions[2] != expectedPausedBusyResumeAction { diff --git a/cli/common/errors/busy_status.go b/cli/common/errors/busy_status.go index 5dd6417f35..4da2bddcf3 100644 --- a/cli/common/errors/busy_status.go +++ b/cli/common/errors/busy_status.go @@ -17,11 +17,12 @@ func unityServerBusyMessage(fallback string, data serverBusyErrorData, requested // message, so it must not claim that a retry already happened. if data.RunningToolElapsedSeconds != nil { return fmt.Sprintf( - "'%s' was not executed because Unity is busy running '%s' (running for %ds). uloop is single-flight per Editor. Wait for '%s' to complete, then run the command once more; do not retry in a loop.", + "'%s' was not executed because Unity is busy running '%s' (running for %ds). uloop is single-flight per Editor. Wait for '%s' to complete, then run the command once more; do not retry in a loop.%s", requestedToolName, runningToolName, *data.RunningToolElapsedSeconds, - runningToolName) + runningToolName, + busyCancelHint(runningToolName)) } return fmt.Sprintf( "'%s' was not executed because Unity is busy running '%s'. uloop is single-flight per Editor. Wait for '%s' to complete, then run the command once more; do not retry in a loop.", @@ -29,3 +30,13 @@ func unityServerBusyMessage(fallback string, data serverBusyErrorData, requested runningToolName, runningToolName) } + +// busyCancelHint points at uloop cancel only for a busy that came from the execution slot, the +// only kind with elapsed seconds, so the caller already checked that. An Editor-state busy +// (unity-compile, unity-asset-database-update) holds no slot and nothing to cancel. +func busyCancelHint(runningToolName string) string { + if !ClientDisconnectCancelsRunningTool(runningToolName) { + return "" + } + return " Run `uloop cancel` to stop it." +} diff --git a/cli/common/errors/error_envelope.go b/cli/common/errors/error_envelope.go index dd6d0e58db..c4460d6bbf 100644 --- a/cli/common/errors/error_envelope.go +++ b/cli/common/errors/error_envelope.go @@ -22,6 +22,7 @@ const ( errorCodeUnityEditorUnresponsive = "UNITY_EDITOR_UNRESPONSIVE" errorCodeUnityRPCError = "UNITY_RPC_ERROR" errorCodeUnityServerBusy = "UNITY_SERVER_BUSY" + errorCodeToolCancelled = "TOOL_CANCELLED" ErrorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" ErrorCodeV2ProjectDetected = "V2_PROJECT_DETECTED" ErrorCodeToolDisabled = "TOOL_DISABLED" diff --git a/cli/common/errors/error_envelope_classification.go b/cli/common/errors/error_envelope_classification.go index 203f287c19..2c56309d5a 100644 --- a/cli/common/errors/error_envelope_classification.go +++ b/cli/common/errors/error_envelope_classification.go @@ -143,6 +143,8 @@ func classifyRPCError(rpcErr *unityipc.RPCError, context ErrorContext) CLIError return cliUpdateRequiredError(rpcErr, details, decodeCliUpdateRequiredErrorData(rpcErr.Data), context) case "server_busy": return unityServerBusyError(rpcErr, details, decodeServerBusyErrorData(rpcErr.Data), context) + case "tool_cancelled": + return toolCancelledError(rpcErr, details, decodeToolCancelledErrorData(rpcErr.Data), context) default: return genericRPCError(rpcErr, details, context) } diff --git a/cli/common/errors/error_envelope_test.go b/cli/common/errors/error_envelope_test.go index e0e776e288..94269c083b 100644 --- a/cli/common/errors/error_envelope_test.go +++ b/cli/common/errors/error_envelope_test.go @@ -356,7 +356,7 @@ func TestClassifyServerBusyRPCError_WhenPausedInPlayMode_ExplainsTheStallAndReco t.Fatalf("next actions must lead with the pause stall: %#v", cliErr.NextActions) } joinedActions := strings.Join(cliErr.NextActions, "\n") - if !strings.Contains(joinedActions, "Stop the uloop process that is running the command") { + if !strings.Contains(joinedActions, "Run `uloop cancel`") { t.Fatalf("next actions must name the recovery step: %#v", cliErr.NextActions) } editorActivity, ok := cliErr.Details["EditorActivity"].(map[string]any) diff --git a/cli/common/errors/rpc_error_data.go b/cli/common/errors/rpc_error_data.go index b311964edf..124a926f15 100644 --- a/cli/common/errors/rpc_error_data.go +++ b/cli/common/errors/rpc_error_data.go @@ -28,6 +28,20 @@ type cliUpdateRequiredErrorData struct { RetryableAfterUpdate bool `json:"retryableAfterUpdate"` } +// Mirrors Packages/src/Editor/Infrastructure/Api/ToolCancelledErrorData.cs public properties. +type toolCancelledErrorData struct { + Type string `json:"type"` + Message string `json:"message"` + RunningToolName string `json:"runningToolName"` +} + +func decodeToolCancelledErrorData(data json.RawMessage) toolCancelledErrorData { + var decoded toolCancelledErrorData + // Why ignore: keep the previous missing-key = zero-value fallback. + _ = json.Unmarshal(data, &decoded) + return decoded +} + func decodeServerBusyErrorData(data json.RawMessage) serverBusyErrorData { var decoded serverBusyErrorData // Why ignore: keep the previous missing-key = zero-value fallback. A type mismatch diff --git a/cli/common/errors/tool_cancelled_error.go b/cli/common/errors/tool_cancelled_error.go new file mode 100644 index 0000000000..2d79649ae1 --- /dev/null +++ b/cli/common/errors/tool_cancelled_error.go @@ -0,0 +1,32 @@ +package clierrors + +import "github.com/hatayama/unity-cli-loop/common/unityipc" + +// toolCancelledError reports to the CLI that waited for a tool that uloop cancel stopped it. +// Why not retryable: the cancel was asked for on purpose, so running the command again is a new +// decision for the caller, not a recovery. +func toolCancelledError( + rpcErr *unityipc.RPCError, + details map[string]any, + data toolCancelledErrorData, + context ErrorContext, +) CLIError { + message := rpcErr.Message + if data.RunningToolName != "" { + message = "'" + data.RunningToolName + "' was cancelled by uloop cancel." + } + + return CLIError{ + ErrorCode: errorCodeToolCancelled, + Phase: ErrorPhaseExecution, + Message: message, + Retryable: false, + SafeToRetry: false, + ProjectRoot: context.ProjectRoot, + Command: context.Command, + NextActions: []string{ + "Run `uloop status` to confirm the Editor is Ready before the next command.", + }, + Details: details, + } +} diff --git a/cli/common/errors/tool_cancelled_error_contract_test.go b/cli/common/errors/tool_cancelled_error_contract_test.go new file mode 100644 index 0000000000..2d152aac3d --- /dev/null +++ b/cli/common/errors/tool_cancelled_error_contract_test.go @@ -0,0 +1,85 @@ +package clierrors + +import ( + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const toolCancelledErrorContractPath = "tests/contracts/tool_cancelled_error_contract.json" + +type toolCancelledErrorContractFile struct { + Comment string `json:"comment"` + ErrorData json.RawMessage `json:"errorData"` +} + +// Verifies the shared tool_cancelled error.data fixture decodes into every typed struct field. +func TestDecodeToolCancelledErrorData_WhenContractFixture_ReadsEveryField(t *testing.T) { + contract := readToolCancelledErrorContract(t) + + decoded := decodeToolCancelledErrorData(contract.ErrorData) + + expected := toolCancelledErrorData{ + Type: "tool_cancelled", + Message: "'run-tests' was cancelled by uloop cancel.", + RunningToolName: "run-tests", + } + if decoded != expected { + t.Fatalf("decoded contract mismatch:\n got: %#v\nwant: %#v", decoded, expected) + } +} + +// Verifies a tool_cancelled RPC error classifies as TOOL_CANCELLED in the execution phase, is +// not retryable, names the tool, and points at uloop status. +func TestClassifyToolCancelledRPCError_UsesCancelEnvelope(t *testing.T) { + contract := readToolCancelledErrorContract(t) + err := &unityipc.RPCError{ + Code: -32603, + Message: "'run-tests' was cancelled by uloop cancel.", + Data: contract.ErrorData, + } + + cliErr := ClassifyError(err, ErrorContext{ProjectRoot: "/project", Command: "run-tests"}) + + if cliErr.ErrorCode != "TOOL_CANCELLED" { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Phase != ErrorPhaseExecution { + t.Fatalf("phase mismatch: %#v", cliErr) + } + if cliErr.Retryable || cliErr.SafeToRetry { + t.Fatalf("a cancel must not be retryable: %#v", cliErr) + } + if cliErr.Message != "'run-tests' was cancelled by uloop cancel." { + t.Fatalf("message mismatch: %s", cliErr.Message) + } + expectedActions := []string{"Run `uloop status` to confirm the Editor is Ready before the next command."} + if !reflect.DeepEqual(cliErr.NextActions, expectedActions) { + t.Fatalf("next actions mismatch: %#v", cliErr.NextActions) + } + if cliErr.Command != "run-tests" || cliErr.ProjectRoot != "/project" { + t.Fatalf("context mismatch: %#v", cliErr) + } +} + +func readToolCancelledErrorContract(t *testing.T) toolCancelledErrorContractFile { + t.Helper() + + path := findRepoRelativeFile(t, toolCancelledErrorContractPath) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read tool_cancelled error contract: %v", err) + } + + var contract toolCancelledErrorContractFile + if err := json.Unmarshal(data, &contract); err != nil { + t.Fatalf("failed to unmarshal tool_cancelled error contract: %v", err) + } + if len(contract.ErrorData) == 0 { + t.Fatal("tool_cancelled error contract must include errorData") + } + return contract +} diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 2a5d2cf273..562ac1d44d 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "5e9f85506e39907bc1a3010559e2eacef32e8a57" + "sharedInputsHash": "564673937bd604d3d9bfada82840f9f64fa0856b" } diff --git a/cli/project-runner/internal/projectrunner/status_command.go b/cli/project-runner/internal/projectrunner/status_command.go index 76cd3ab4ce..b819011be4 100644 --- a/cli/project-runner/internal/projectrunner/status_command.go +++ b/cli/project-runner/internal/projectrunner/status_command.go @@ -281,10 +281,22 @@ func reportFromEditorResponse(response editorStatusResponse) statusReport { report.RunningToolName = response.RunningToolName report.RunningToolElapsedSeconds = response.RunningToolElapsedSeconds report.RunningToolPhase = response.RunningToolPhase + report.NextActions = append(report.NextActions, busyCancelNextActions(response)...) } return report } +// busyCancelNextActions offers uloop cancel only for a tool it can stop. Why the tool name and +// not the request: status sees only the name, so run-tests and compile, which uloop cancel +// leaves running in some modes, never get a promise that may not hold. +func busyCancelNextActions(response editorStatusResponse) []string { + if response.RunningToolElapsedSeconds == nil || + !clierrors.ClientDisconnectCancelsRunningTool(response.RunningToolName) { + return nil + } + return []string{"If it does not finish, run `uloop cancel` to stop it."} +} + // classifyEditorState picks the state of an Editor that answered. The order is the contract: a // running command explains a busy main thread, and the compile values are refreshed by the main // thread, so they are stale while it is blocked. diff --git a/cli/project-runner/internal/projectrunner/status_command_test.go b/cli/project-runner/internal/projectrunner/status_command_test.go index fb6ed089f6..fe3fbeb013 100644 --- a/cli/project-runner/internal/projectrunner/status_command_test.go +++ b/cli/project-runner/internal/projectrunner/status_command_test.go @@ -106,6 +106,39 @@ func TestRunStatusCommandReportsBusyBeforeMainThreadBlocked(t *testing.T) { assertStatusRequestCount(t, server, 1) } +// Verifies a Busy report offers uloop cancel after the wait steps when a tool uloop cancel can +// stop holds the slot, and leaves it out for run-tests and compile. +func TestRunStatusCommandBusyNextActionsOfferCancelOnlyForCancellableTools(t *testing.T) { + waitSteps := []string{ + "Wait for the running command to finish, then run uloop status again.", + "Another command sent now is rejected as busy.", + } + cases := []struct { + toolName string + want []string + }{ + {toolName: "execute-dynamic-code", want: append(append([]string{}, waitSteps...), "If it does not finish, run `uloop cancel` to stop it.")}, + {toolName: "run-tests", want: waitSteps}, + {toolName: "compile", want: waitSteps}, + } + for _, testCase := range cases { + t.Run(testCase.toolName, func(t *testing.T) { + elapsedSeconds := 40 + report := reportFromEditorResponse(editorStatusResponse{ + IsBusy: true, + RunningToolName: testCase.toolName, + RunningToolElapsedSeconds: &elapsedSeconds, + RunningToolPhase: "Executing", + HasEditorState: true, + }) + + if !reflect.DeepEqual(report.NextActions, testCase.want) { + t.Fatalf("next actions mismatch:\n got: %#v\nwant: %#v", report.NextActions, testCase.want) + } + }) + } +} + // Verifies Busy is decided before the missing Editor state, and that the play and compile // values are left out when the Editor has not recorded them. func TestRunStatusCommandReportsBusyBeforeStarting(t *testing.T) { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 20e2e5c9d0..2160b2bce6 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "8734909b7884cd0bf8e85a9863b152296ae0a135" + "sharedInputsHash": "0cc3721341b8cea1b482cfdec8b2a58cfb7f0be7" } From d9296dfd5a6c637e43eceb56daf762213dfa7e27 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 16:45:26 +0900 Subject: [PATCH 6/9] docs: Add the uloop-cancel skill and point recovery guidance at it The new uloop-cancel skill explains when to cancel, the three outcomes and their exit codes, which commands cannot be cancelled, what the waiting command receives, and why uloop status should confirm Ready afterwards. cancel --help now ends by pointing at it. The status skill, the pause-point troubleshooting reference, and the execute-dynamic-code cancellation reference now name uloop cancel where they told the reader to kill the waiting CLI. The glossary and ADR 0012 list cancel-running-tool beside get-editor-status as the commands the router answers off the main thread and outside the activity hold. --- .agents/skills/uloop-cancel/SKILL.md | 63 +++++++++++++++++++ .../references/cancellation.md | 7 ++- .../references/troubleshooting.md | 2 +- .agents/skills/uloop-status/SKILL.md | 1 + .claude/skills/uloop-cancel/SKILL.md | 63 +++++++++++++++++++ .../references/cancellation.md | 7 ++- .../references/troubleshooting.md | 2 +- .claude/skills/uloop-status/SKILL.md | 1 + .../CliOnlyTools~/Cancel/Skill/SKILL.md | 63 +++++++++++++++++++ .../Skill/references/troubleshooting.md | 2 +- .../CliOnlyTools~/Status/Skill/SKILL.md | 1 + .../Skill/references/cancellation.md | 7 ++- cli/common/tooldocs/skill_guidance.go | 1 + cli/dispatcher/shared-inputs-stamp.json | 2 +- .../projectrunner/native_command_help_test.go | 6 +- cli/project-runner/shared-inputs-stamp.json | 2 +- ...d-a-macos-activity-while-a-command-runs.md | 6 +- docs/glossary.md | 7 ++- 18 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 .agents/skills/uloop-cancel/SKILL.md create mode 100644 .claude/skills/uloop-cancel/SKILL.md create mode 100644 Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md diff --git a/.agents/skills/uloop-cancel/SKILL.md b/.agents/skills/uloop-cancel/SKILL.md new file mode 100644 index 0000000000..cbe6ea5e82 --- /dev/null +++ b/.agents/skills/uloop-cancel/SKILL.md @@ -0,0 +1,63 @@ +--- +name: uloop-cancel +description: "Cancel the uloop command the Unity Editor for this project is running now. Use when BUSY keeps rejecting commands or your own command does not finish." +--- + +# uloop cancel + +Cancel the uloop command that holds the Editor's command slot, from another terminal or after the waiting command was given up on. + +## When to Use + +- Every command is rejected with `UNITY_SERVER_BUSY` and the running command shows no sign of finishing. +- A command you started does not return, for example an `execute-dynamic-code` snippet that awaits something that never completes. + +## Usage + +```bash +uloop cancel +uloop cancel --project-path +``` + +It takes no other options. It answers while Unity is busy and while a command blocks Unity's main thread. + +## Output + +Returns JSON with: + +- `Outcome`: what the cancel found +- `RunningToolName`, `RunningToolElapsedSeconds`: the command that held the slot; `null` for `NothingRunning` +- `Success`: `false` only for `NotCancellable` + +| Outcome | Meaning | Exit code | +|---------|---------|-----------| +| `Cancelled` | The running command was asked to stop | 0 | +| `NothingRunning` | No command held the slot | 0 | +| `NotCancellable` | The running command cannot be cancelled and keeps running | 1 | + +When Unity cannot be reached, the error goes to stderr, nothing goes to stdout, and it exits 1. A Unity package too old to support `uloop cancel` answers with a `UNITY_RPC_ERROR` naming the unknown command. + +## What Cannot Be Cancelled + +`NotCancellable` covers only these: + +- `compile` that waits for the domain reload (the default) +- `run-tests --test-mode PlayMode --respect-enter-play-mode-settings` +- a tool started by code inside the Editor (an extension or an Editor window), not by the CLI + +A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be cancelled. + +## What the Waiting Command Gets + +- Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). +- `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". + +When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. + +## After Cancelling + +Run `uloop status` and wait for `Ready` before the next command. `Cancelled` means the command was asked to stop, not that it already stopped: + +- A tool that ignores its cancellation token keeps the slot until it returns on its own. +- `execute-dynamic-code` returns at once and frees the slot, but a snippet that ignores `ct` keeps running in the background. +- While a command blocks Unity's main thread, `execute-dynamic-code` cannot return; its slot is freed about 5 seconds later, when another command arrives. diff --git a/.agents/skills/uloop-execute-dynamic-code/references/cancellation.md b/.agents/skills/uloop-execute-dynamic-code/references/cancellation.md index 6f93efc7df..fb9dbbea23 100644 --- a/.agents/skills/uloop-execute-dynamic-code/references/cancellation.md +++ b/.agents/skills/uloop-execute-dynamic-code/references/cancellation.md @@ -16,11 +16,12 @@ So a `CancellationToken` named `ct` is always in scope; use it without declaring - a domain reload starts, - the Unity Editor quits, - the uloop server inside the Editor stops or restarts, -- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to. +- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to, +- `uloop cancel` is run while the snippet is running. ## Long awaits block other tool commands -uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. +uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. To stop a running snippet from another terminal, run `uloop cancel`. Another `execute-dynamic-code` is not rejected as BUSY: it returns `Success: false` with `ErrorMessage` "Another execution is already in progress". Since the next command an agent sends is often another snippet, treat that message as the sign that an earlier snippet is still awaiting. @@ -58,6 +59,6 @@ uloop stops waiting for the snippet and frees the Editor for the next command. A What the CLI that sent the snippet sees depends on why `ct` fired: -- Yielded to a foreground request: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". +- Yielded to a foreground request, or stopped by `uloop cancel`: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". - The CLI disconnected: no response is sent; the CLI that was waiting is already gone. - Domain reload, Editor quit, or server stop/restart: no response arrives. The CLI treats it as a lost connection: after a domain reload it prints "Connection lost during execute-dynamic-code", waits for the Editor to come back, and then reports the disconnect as an error. Check the Editor with `uloop status` before sending the next command. diff --git a/.agents/skills/uloop-pause-point/references/troubleshooting.md b/.agents/skills/uloop-pause-point/references/troubleshooting.md index 3e5bdd4825..27b17c0789 100644 --- a/.agents/skills/uloop-pause-point/references/troubleshooting.md +++ b/.agents/skills/uloop-pause-point/references/troubleshooting.md @@ -24,7 +24,7 @@ A command that waits for a frame or a physics step cannot finish while the Edito `uloop pause-point-status` still answers while Unity is busy — use it to confirm a hit is holding the pause. Then recover either way: -1. Stop the uloop process that is running the command (Ctrl-C in its terminal, otherwise interrupt or kill that process). Its request is cancelled and returns no result, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancelled when their client disconnects, so stopping the process leaves the pause in place — use step 2 for those. +1. Run `uloop cancel` (it answers while Unity is busy). The running command is cancelled, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancellable, so `uloop cancel` reports `NotCancellable` and leaves the pause in place — use step 2 for those. 2. Release the pause in the Editor (Edit > Play Mode > Pause). Frames resume, so the running command finishes and returns its result. Avoid the state instead: never start a frame-waiting snippet while a marker is armed or while the Editor is paused — starting it after the hit deadlocks the same way. Take the hit first (`await-pause-point`, or `enable-pause-point --await`) and inspect the capture; when you are done, resume play (`control-play-mode --action Resume` — the gate is free at that point) and only then run the frame-waiting snippet. While the pause holds, run only snippets that do not wait for a frame or a physics step. diff --git a/.agents/skills/uloop-status/SKILL.md b/.agents/skills/uloop-status/SKILL.md index 5e0267133c..7ce35564f8 100644 --- a/.agents/skills/uloop-status/SKILL.md +++ b/.agents/skills/uloop-status/SKILL.md @@ -52,6 +52,7 @@ Play Mode does not change the state: a paused Play Mode session is `Ready`, with ## Notes - Exits 0 only for Ready. +- When `Busy` does not clear and the running command shows no sign of finishing, stop it with `uloop cancel`. - It never focuses the window, launches Unity, or takes the command slot, so it is safe to poll. - A sandboxed shell that cannot open the Unix socket gets an error, not NotRunning; run it outside the sandbox. - uloop cannot detect Safe Mode directly; ServerUnavailable that does not clear is the signal. diff --git a/.claude/skills/uloop-cancel/SKILL.md b/.claude/skills/uloop-cancel/SKILL.md new file mode 100644 index 0000000000..cbe6ea5e82 --- /dev/null +++ b/.claude/skills/uloop-cancel/SKILL.md @@ -0,0 +1,63 @@ +--- +name: uloop-cancel +description: "Cancel the uloop command the Unity Editor for this project is running now. Use when BUSY keeps rejecting commands or your own command does not finish." +--- + +# uloop cancel + +Cancel the uloop command that holds the Editor's command slot, from another terminal or after the waiting command was given up on. + +## When to Use + +- Every command is rejected with `UNITY_SERVER_BUSY` and the running command shows no sign of finishing. +- A command you started does not return, for example an `execute-dynamic-code` snippet that awaits something that never completes. + +## Usage + +```bash +uloop cancel +uloop cancel --project-path +``` + +It takes no other options. It answers while Unity is busy and while a command blocks Unity's main thread. + +## Output + +Returns JSON with: + +- `Outcome`: what the cancel found +- `RunningToolName`, `RunningToolElapsedSeconds`: the command that held the slot; `null` for `NothingRunning` +- `Success`: `false` only for `NotCancellable` + +| Outcome | Meaning | Exit code | +|---------|---------|-----------| +| `Cancelled` | The running command was asked to stop | 0 | +| `NothingRunning` | No command held the slot | 0 | +| `NotCancellable` | The running command cannot be cancelled and keeps running | 1 | + +When Unity cannot be reached, the error goes to stderr, nothing goes to stdout, and it exits 1. A Unity package too old to support `uloop cancel` answers with a `UNITY_RPC_ERROR` naming the unknown command. + +## What Cannot Be Cancelled + +`NotCancellable` covers only these: + +- `compile` that waits for the domain reload (the default) +- `run-tests --test-mode PlayMode --respect-enter-play-mode-settings` +- a tool started by code inside the Editor (an extension or an Editor window), not by the CLI + +A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be cancelled. + +## What the Waiting Command Gets + +- Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). +- `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". + +When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. + +## After Cancelling + +Run `uloop status` and wait for `Ready` before the next command. `Cancelled` means the command was asked to stop, not that it already stopped: + +- A tool that ignores its cancellation token keeps the slot until it returns on its own. +- `execute-dynamic-code` returns at once and frees the slot, but a snippet that ignores `ct` keeps running in the background. +- While a command blocks Unity's main thread, `execute-dynamic-code` cannot return; its slot is freed about 5 seconds later, when another command arrives. diff --git a/.claude/skills/uloop-execute-dynamic-code/references/cancellation.md b/.claude/skills/uloop-execute-dynamic-code/references/cancellation.md index 6f93efc7df..fb9dbbea23 100644 --- a/.claude/skills/uloop-execute-dynamic-code/references/cancellation.md +++ b/.claude/skills/uloop-execute-dynamic-code/references/cancellation.md @@ -16,11 +16,12 @@ So a `CancellationToken` named `ct` is always in scope; use it without declaring - a domain reload starts, - the Unity Editor quits, - the uloop server inside the Editor stops or restarts, -- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to. +- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to, +- `uloop cancel` is run while the snippet is running. ## Long awaits block other tool commands -uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. +uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. To stop a running snippet from another terminal, run `uloop cancel`. Another `execute-dynamic-code` is not rejected as BUSY: it returns `Success: false` with `ErrorMessage` "Another execution is already in progress". Since the next command an agent sends is often another snippet, treat that message as the sign that an earlier snippet is still awaiting. @@ -58,6 +59,6 @@ uloop stops waiting for the snippet and frees the Editor for the next command. A What the CLI that sent the snippet sees depends on why `ct` fired: -- Yielded to a foreground request: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". +- Yielded to a foreground request, or stopped by `uloop cancel`: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". - The CLI disconnected: no response is sent; the CLI that was waiting is already gone. - Domain reload, Editor quit, or server stop/restart: no response arrives. The CLI treats it as a lost connection: after a domain reload it prints "Connection lost during execute-dynamic-code", waits for the Editor to come back, and then reports the disconnect as an error. Check the Editor with `uloop status` before sending the next command. diff --git a/.claude/skills/uloop-pause-point/references/troubleshooting.md b/.claude/skills/uloop-pause-point/references/troubleshooting.md index 3e5bdd4825..27b17c0789 100644 --- a/.claude/skills/uloop-pause-point/references/troubleshooting.md +++ b/.claude/skills/uloop-pause-point/references/troubleshooting.md @@ -24,7 +24,7 @@ A command that waits for a frame or a physics step cannot finish while the Edito `uloop pause-point-status` still answers while Unity is busy — use it to confirm a hit is holding the pause. Then recover either way: -1. Stop the uloop process that is running the command (Ctrl-C in its terminal, otherwise interrupt or kill that process). Its request is cancelled and returns no result, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancelled when their client disconnects, so stopping the process leaves the pause in place — use step 2 for those. +1. Run `uloop cancel` (it answers while Unity is busy). The running command is cancelled, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancellable, so `uloop cancel` reports `NotCancellable` and leaves the pause in place — use step 2 for those. 2. Release the pause in the Editor (Edit > Play Mode > Pause). Frames resume, so the running command finishes and returns its result. Avoid the state instead: never start a frame-waiting snippet while a marker is armed or while the Editor is paused — starting it after the hit deadlocks the same way. Take the hit first (`await-pause-point`, or `enable-pause-point --await`) and inspect the capture; when you are done, resume play (`control-play-mode --action Resume` — the gate is free at that point) and only then run the frame-waiting snippet. While the pause holds, run only snippets that do not wait for a frame or a physics step. diff --git a/.claude/skills/uloop-status/SKILL.md b/.claude/skills/uloop-status/SKILL.md index 5e0267133c..7ce35564f8 100644 --- a/.claude/skills/uloop-status/SKILL.md +++ b/.claude/skills/uloop-status/SKILL.md @@ -52,6 +52,7 @@ Play Mode does not change the state: a paused Play Mode session is `Ready`, with ## Notes - Exits 0 only for Ready. +- When `Busy` does not clear and the running command shows no sign of finishing, stop it with `uloop cancel`. - It never focuses the window, launches Unity, or takes the command slot, so it is safe to poll. - A sandboxed shell that cannot open the Unix socket gets an error, not NotRunning; run it outside the sandbox. - uloop cannot detect Safe Mode directly; ServerUnavailable that does not clear is the signal. diff --git a/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md new file mode 100644 index 0000000000..cbe6ea5e82 --- /dev/null +++ b/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md @@ -0,0 +1,63 @@ +--- +name: uloop-cancel +description: "Cancel the uloop command the Unity Editor for this project is running now. Use when BUSY keeps rejecting commands or your own command does not finish." +--- + +# uloop cancel + +Cancel the uloop command that holds the Editor's command slot, from another terminal or after the waiting command was given up on. + +## When to Use + +- Every command is rejected with `UNITY_SERVER_BUSY` and the running command shows no sign of finishing. +- A command you started does not return, for example an `execute-dynamic-code` snippet that awaits something that never completes. + +## Usage + +```bash +uloop cancel +uloop cancel --project-path +``` + +It takes no other options. It answers while Unity is busy and while a command blocks Unity's main thread. + +## Output + +Returns JSON with: + +- `Outcome`: what the cancel found +- `RunningToolName`, `RunningToolElapsedSeconds`: the command that held the slot; `null` for `NothingRunning` +- `Success`: `false` only for `NotCancellable` + +| Outcome | Meaning | Exit code | +|---------|---------|-----------| +| `Cancelled` | The running command was asked to stop | 0 | +| `NothingRunning` | No command held the slot | 0 | +| `NotCancellable` | The running command cannot be cancelled and keeps running | 1 | + +When Unity cannot be reached, the error goes to stderr, nothing goes to stdout, and it exits 1. A Unity package too old to support `uloop cancel` answers with a `UNITY_RPC_ERROR` naming the unknown command. + +## What Cannot Be Cancelled + +`NotCancellable` covers only these: + +- `compile` that waits for the domain reload (the default) +- `run-tests --test-mode PlayMode --respect-enter-play-mode-settings` +- a tool started by code inside the Editor (an extension or an Editor window), not by the CLI + +A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be cancelled. + +## What the Waiting Command Gets + +- Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). +- `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". + +When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. + +## After Cancelling + +Run `uloop status` and wait for `Ready` before the next command. `Cancelled` means the command was asked to stop, not that it already stopped: + +- A tool that ignores its cancellation token keeps the slot until it returns on its own. +- `execute-dynamic-code` returns at once and frees the slot, but a snippet that ignores `ct` keeps running in the background. +- While a command blocks Unity's main thread, `execute-dynamic-code` cannot return; its slot is freed about 5 seconds later, when another command arrives. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md index 3e5bdd4825..27b17c0789 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/troubleshooting.md @@ -24,7 +24,7 @@ A command that waits for a frame or a physics step cannot finish while the Edito `uloop pause-point-status` still answers while Unity is busy — use it to confirm a hit is holding the pause. Then recover either way: -1. Stop the uloop process that is running the command (Ctrl-C in its terminal, otherwise interrupt or kill that process). Its request is cancelled and returns no result, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancelled when their client disconnects, so stopping the process leaves the pause in place — use step 2 for those. +1. Run `uloop cancel` (it answers while Unity is busy). The running command is cancelled, the Editor pause is released, and the next command runs. This does not apply to every tool: a `run-tests` run that respects Enter Play Mode settings, and a `compile` that waits for a domain reload, are deliberately not cancellable, so `uloop cancel` reports `NotCancellable` and leaves the pause in place — use step 2 for those. 2. Release the pause in the Editor (Edit > Play Mode > Pause). Frames resume, so the running command finishes and returns its result. Avoid the state instead: never start a frame-waiting snippet while a marker is armed or while the Editor is paused — starting it after the hit deadlocks the same way. Take the hit first (`await-pause-point`, or `enable-pause-point --await`) and inspect the capture; when you are done, resume play (`control-play-mode --action Resume` — the gate is free at that point) and only then run the frame-waiting snippet. While the pause holds, run only snippets that do not wait for a frame or a physics step. diff --git a/Packages/src/Editor/CliOnlyTools~/Status/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Status/Skill/SKILL.md index 5e0267133c..7ce35564f8 100644 --- a/Packages/src/Editor/CliOnlyTools~/Status/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Status/Skill/SKILL.md @@ -52,6 +52,7 @@ Play Mode does not change the state: a paused Play Mode session is `Ready`, with ## Notes - Exits 0 only for Ready. +- When `Busy` does not clear and the running command shows no sign of finishing, stop it with `uloop cancel`. - It never focuses the window, launches Unity, or takes the command slot, so it is safe to poll. - A sandboxed shell that cannot open the Unix socket gets an error, not NotRunning; run it outside the sandbox. - uloop cannot detect Safe Mode directly; ServerUnavailable that does not clear is the signal. diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/cancellation.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/cancellation.md index 6f93efc7df..fb9dbbea23 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/cancellation.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/references/cancellation.md @@ -16,11 +16,12 @@ So a `CancellationToken` named `ct` is always in scope; use it without declaring - a domain reload starts, - the Unity Editor quits, - the uloop server inside the Editor stops or restarts, -- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to. +- the snippet was sent with `--yield-to-foreground-requests` and another `execute-dynamic-code` request arrives that it yields to, +- `uloop cancel` is run while the snippet is running. ## Long awaits block other tool commands -uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. +uloop runs one tool command at a time per Editor. While a snippet is awaiting, other tool commands against that Editor (such as `compile`, `get-logs`, or `run-tests`) are rejected as BUSY. An await that may never finish — waiting for an animation or a scripted sequence to complete, or for an external event — keeps the Editor BUSY for as long as it waits. `uloop status` still answers while a tool runs, so use it to check what is running. To stop a running snippet from another terminal, run `uloop cancel`. Another `execute-dynamic-code` is not rejected as BUSY: it returns `Success: false` with `ErrorMessage` "Another execution is already in progress". Since the next command an agent sends is often another snippet, treat that message as the sign that an earlier snippet is still awaiting. @@ -58,6 +59,6 @@ uloop stops waiting for the snippet and frees the Editor for the next command. A What the CLI that sent the snippet sees depends on why `ct` fired: -- Yielded to a foreground request: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". +- Yielded to a foreground request, or stopped by `uloop cancel`: the connection is still open, so the CLI receives `Success: false` with `ErrorMessage` "Execution was cancelled or timed out". - The CLI disconnected: no response is sent; the CLI that was waiting is already gone. - Domain reload, Editor quit, or server stop/restart: no response arrives. The CLI treats it as a lost connection: after a domain reload it prints "Connection lost during execute-dynamic-code", waits for the Editor to come back, and then reports the disconnect as an error. Check the Editor with `uloop status` before sending the next command. diff --git a/cli/common/tooldocs/skill_guidance.go b/cli/common/tooldocs/skill_guidance.go index 5af67f9f5c..d568e86830 100644 --- a/cli/common/tooldocs/skill_guidance.go +++ b/cli/common/tooldocs/skill_guidance.go @@ -26,6 +26,7 @@ var commandSkillNames = map[string]string{ "launch": "uloop-launch", "status": "uloop-status", + "cancel": "uloop-cancel", // One skill covers the four pause-point commands and the three watch commands: watch // expressions are documented by the pause-point skill's references/watch-expressions.md. diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 562ac1d44d..d42264d518 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "564673937bd604d3d9bfada82840f9f64fa0856b" + "sharedInputsHash": "80d3e17157a6a4f83ef753f156b0f56e3bc5c987" } diff --git a/cli/project-runner/internal/projectrunner/native_command_help_test.go b/cli/project-runner/internal/projectrunner/native_command_help_test.go index 89506ba8ea..2ad8f016ea 100644 --- a/cli/project-runner/internal/projectrunner/native_command_help_test.go +++ b/cli/project-runner/internal/projectrunner/native_command_help_test.go @@ -141,7 +141,7 @@ func TestStatusHelpPrintsUsage(t *testing.T) { } // Verifies cancel --help prints its usage line and registry description with only the global -// option, since cancel takes no options of its own. +// option, since cancel takes no options of its own, and ends by pointing at the cancel skill. func TestCancelHelpPrintsUsage(t *testing.T) { t.Chdir(t.TempDir()) var stdout bytes.Buffer @@ -158,7 +158,9 @@ func TestCancelHelpPrintsUsage(t *testing.T) { "Cancel the tool the Unity Editor for this project is running now\n" + "\n" + "Global options:\n" + - " --project-path Run against a Unity project outside the current directory\n" + " --project-path Run against a Unity project outside the current directory\n" + + "\n" + + "Load the uloop-cancel skill for workflow rules and response fields that --help does not cover.\n" if stdout.String() != expected { t.Fatalf("cancel --help output mismatch:\n got:\n%s\nwant:\n%s", stdout.String(), expected) } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 2160b2bce6..3dabb7f620 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "0cc3721341b8cea1b482cfdec8b2a58cfb7f0be7" + "sharedInputsHash": "8fd2a725aff532225513cbebd13997cd7c1f66d5" } diff --git a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md index 7b9d9744bd..ba1583a7bc 100644 --- a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md +++ b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md @@ -7,9 +7,9 @@ Date: 2026-10-07 While the Unity Editor processes a command — a tool or an internal bridge command — it holds a macOS process activity started with `NSActivityUserInitiatedAllowingIdleSystemSleep`. The activity begins when the execution router starts the command and ends when the router returns, -whether the command succeeded, failed, or was canceled. `get-editor-status` holds nothing: it -answers before the router starts any command, and it must keep answering while the main thread -is stuck. Every activity still held is ended before a domain reload. Other platforms hold +whether the command succeeded, failed, or was canceled. `get-editor-status` and +`cancel-running-tool` hold nothing: they answer before the router starts any command, and they +must keep answering while the main thread is stuck. Every activity still held is ended before a domain reload. Other platforms hold nothing. ## Context diff --git a/docs/glossary.md b/docs/glossary.md index d4c1befc20..c2e938c7e7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -43,9 +43,10 @@ an agent can run (for example `compile`, `get-logs`, `run-tests`). Tool names us A CLI-only request handled inside the Unity package that must not appear in the extension-facing tool registry (for example `get-version`, `get-compile-status`). Routed by `InternalBridgeCommandRouter`; names use `UnityCliLoopConstants.COMMAND_NAME_*` -constants. The one exception is `get-editor-status`: `UnityCliLoopExecutionRouter` answers it -before switching to the Editor main thread, without going through -`InternalBridgeCommandRouter`, because `uloop status` must answer while that thread is blocked. +constants. The exceptions are `get-editor-status` and `cancel-running-tool`: +`UnityCliLoopExecutionRouter` answers them before switching to the Editor main thread, without +going through `InternalBridgeCommandRouter`, because `uloop status` and `uloop cancel` must +answer while that thread is blocked. Anything an end user or third-party extension should call is a tool, not an internal bridge command. From 84eff445a94fbbf6f83ae2d93f8acd40d217bbac Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 17:03:37 +0900 Subject: [PATCH 7/9] ci: Raise the dead-code PublicCandidate ceiling for ToolCancelledErrorData.type ToolCancelledErrorData overrides the abstract JsonRpcErrorData.type, the wire discriminator the CLI reads as error.data.type. Roslyn finds no C# reader for it, like every other JsonRpcErrorData subclass, and docs/dead-code-scanner.md already lists that group as intentionally retained, so the measured count rises from 37 to 38. --- .github/workflows/dead-code.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dead-code.yml b/.github/workflows/dead-code.yml index 5a41d36d39..3c514ad03b 100644 --- a/.github/workflows/dead-code.yml +++ b/.github/workflows/dead-code.yml @@ -49,4 +49,4 @@ jobs: --include-kept false --format table --fail-on high-confidence - --max-public-candidates 37 + --max-public-candidates 38 From ccea777f6c1ffaa5b4122d402addbb2fcc9ffe0c Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 18:34:32 +0900 Subject: [PATCH 8/9] fix: Offer uloop cancel in BUSY only for a command that does not finish The BUSY message tells other agents to wait for the running command and run theirs once more. An unconditional "Run uloop cancel to stop it" contradicted that and invited them to stop a command that was running normally. Use the same conditional wording as uloop status. --- cli/common/errors/busy_cancel_hint_test.go | 2 +- cli/common/errors/busy_status.go | 2 +- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/common/errors/busy_cancel_hint_test.go b/cli/common/errors/busy_cancel_hint_test.go index 6d231785b2..60190bbaa1 100644 --- a/cli/common/errors/busy_cancel_hint_test.go +++ b/cli/common/errors/busy_cancel_hint_test.go @@ -8,7 +8,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/unityipc" ) -const busyCancelHintText = "Run `uloop cancel` to stop it." +const busyCancelHintText = "If it does not finish, run `uloop cancel` to stop it." func classifyBusyForCancelHint(t *testing.T, data string) CLIError { t.Helper() diff --git a/cli/common/errors/busy_status.go b/cli/common/errors/busy_status.go index 4da2bddcf3..85493bc7c2 100644 --- a/cli/common/errors/busy_status.go +++ b/cli/common/errors/busy_status.go @@ -38,5 +38,5 @@ func busyCancelHint(runningToolName string) string { if !ClientDisconnectCancelsRunningTool(runningToolName) { return "" } - return " Run `uloop cancel` to stop it." + return " If it does not finish, run `uloop cancel` to stop it." } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index d42264d518..b1ccbd4eaa 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "80d3e17157a6a4f83ef753f156b0f56e3bc5c987" + "sharedInputsHash": "7ea8c4dfb4c15e4e72c12cf2bffab9b5d2efaa91" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 3dabb7f620..ef89b990c5 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "8fd2a725aff532225513cbebd13997cd7c1f66d5" + "sharedInputsHash": "02dc2c02515c2f7da4b86cffc8ba6b4bd298a37e" } From 4710b9cf92bae55dc5bdd91d9f5b719ea3e252e6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 18:35:53 +0900 Subject: [PATCH 9/9] docs: Say uloop cancel releases any Editor pause, and pin it for a manual pause A cancel that fires resumes Play Mode through the same path as a killed CLI, which checks only whether the Editor is paused, so it also releases a manual pause that no pause point owns. Keep that behaviour, since uloop cancel replaces the Ctrl-C step, and make the skill and the bridge command comment say so. Add a test that pins it. Also correct the bridge command comment: only continuations registered synchronously on the token have run when CancelRunningTool returns; asynchronously resumed ones are merely scheduled. --- .agents/skills/uloop-cancel/SKILL.md | 2 +- .claude/skills/uloop-cancel/SKILL.md | 2 +- Assets/Tests/Editor/PausePointTests.cs | 21 +++++++++++++++++++ .../CliOnlyTools~/Cancel/Skill/SKILL.md | 2 +- .../Api/CancelRunningToolBridgeCommand.cs | 12 ++++++----- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.agents/skills/uloop-cancel/SKILL.md b/.agents/skills/uloop-cancel/SKILL.md index cbe6ea5e82..df4da5e474 100644 --- a/.agents/skills/uloop-cancel/SKILL.md +++ b/.agents/skills/uloop-cancel/SKILL.md @@ -52,7 +52,7 @@ A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be - Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). - `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". -When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. +`Cancelled` also resumes Play Mode when the Editor is paused, whether a pause point or a manual pause holds it — the same as when the waiting CLI is killed. ## After Cancelling diff --git a/.claude/skills/uloop-cancel/SKILL.md b/.claude/skills/uloop-cancel/SKILL.md index cbe6ea5e82..df4da5e474 100644 --- a/.claude/skills/uloop-cancel/SKILL.md +++ b/.claude/skills/uloop-cancel/SKILL.md @@ -52,7 +52,7 @@ A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be - Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). - `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". -When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. +`Cancelled` also resumes Play Mode when the Editor is paused, whether a pause point or a manual pause holds it — the same as when the waiting CLI is killed. ## After Cancelling diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 2d6e60c255..b7012ce27d 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1217,6 +1217,27 @@ public void CancelRunningToolBridgeCommand_WhenToolIsCancelled_ShouldResumePause lease.Dispose(); } + /// + /// Verifies uloop cancel that stops a running tool also releases a manual Editor pause that + /// no pause point owns, the same as when the waiting CLI is killed. + /// + [Test] + public void CancelRunningToolBridgeCommand_WhenEditorIsManuallyPaused_ShouldResumePauseOnMainThreadApply() + { + _pauseController.PauseExternally(); + ToolExecutionSession session = new ToolExecutionSession(); + ToolExecutionLease lease = session.TryEnter("run-tests").Lease; + + CancelRunningToolResponse response = CancelRunningToolBridgeCommand.Execute(CreateRegistrarService(session)); + UloopPausePointRegistry.ApplyPendingClientDisconnectResume(); + + Assert.That(response.Outcome, Is.EqualTo("Cancelled")); + Assert.That(_pauseController.IsPaused, Is.False); + Assert.That(_pauseController.ResumeCount, Is.EqualTo(1)); + + lease.Dispose(); + } + /// /// Verifies uloop cancel that cannot stop the running tool leaves the pause point's Editor /// pause alone and reports the refusal as a failure. diff --git a/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md index cbe6ea5e82..df4da5e474 100644 --- a/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Cancel/Skill/SKILL.md @@ -52,7 +52,7 @@ A default EditMode `run-tests` and `compile --no-wait-for-domain-reload` can be - Most commands fail with `ErrorCode` `TOOL_CANCELLED` (not retryable). - `execute-dynamic-code` returns its usual cancelled result: `Success: false` with "Execution was cancelled or timed out". -When a pause point's hit holds Play Mode paused, `Cancelled` also releases that pause. +`Cancelled` also resumes Play Mode when the Editor is paused, whether a pause point or a manual pause holds it — the same as when the waiting CLI is killed. ## After Cancelling diff --git a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs index 3251fd9107..64172d48ba 100644 --- a/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/CancelRunningToolBridgeCommand.cs @@ -12,8 +12,9 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure internal static class CancelRunningToolBridgeCommand { // Why no main-thread switch: this must answer while a tool blocks the main thread. The - // cancellation has already fired, and the cancelled requests' continuations already ran, - // when CancelRunningTool returns, so the response is built from its result. + // cancellation has already fired when CancelRunningTool returns: continuations registered + // synchronously on the token have run, and others are only scheduled, so the response is + // built from its result rather than from whether the tool has stopped. internal static CancelRunningToolResponse Execute(UnityCliLoopToolRegistrarService toolRegistrarService) { if (toolRegistrarService == null) @@ -24,9 +25,10 @@ internal static CancelRunningToolResponse Execute(UnityCliLoopToolRegistrarServi UnityCliLoopCancelRequestResult result = toolRegistrarService.CancelRunningTool(); if (result.FiredCancellation) { - // Why: a tool waiting for frames while a pause point holds Play Mode paused would - // never see its cancellation through. This only sets a flag; the next main-thread - // tick releases the pause. + // Why: a tool waiting for frames while Play Mode is paused would never see its + // cancellation through. Like a killed CLI, this releases any Editor pause, whether + // a pause point or a manual pause holds it. This only sets a flag; the next + // main-thread tick releases the pause. UloopPausePointRegistry.ResumeEditorPauseForClientDisconnect(); }