You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
cts.Cancel() doesn't return until the process tree is killed and the caller's own catch block has run inline, which deadlocks a caller that cancels while holding a lock #103
AsyncProcessStreamReader.Start (RunCommand/AsyncProcessStreamReader.cs:56) creates its cancellation signal as new TaskCompletionSource<bool>(), without TaskCreationOptions.RunContinuationsAsynchronously. The token registration calls TrySetResult from inside CancellationTokenSource.Cancel(), so everything waiting on that task runs synchronously on the cancelling thread:
The DrainOrAbandon continuation runs.
RunAsync resumes, and WaitForExitAsync(alreadyCancelledToken) throws.
The catch at RunCommand.cs:504-512 runs TryKill, which kills the whole process tree.
ExecuteAsync faults with OperationCanceledException.
The caller'sawait / catch continuation runs.
All five steps happen before Cancel() returns. For CancelAfter, they run on the timer thread.
Repro (net10.0)
The worker awaits ExecuteAsync("sleep", ["100"], …, cts.Token), and the main thread calls cts.Cancel():
caller continuation ran; insideCancel=True
Cancel() took 58.2ms on thread 4
continuation thread 4
In a second test, the cancelling thread holds a SemaphoreSlim that the worker's catch (OperationCanceledException) also needs:
baseline (Process.WaitForExitAsync(token)): Cancel() returned / OK
RunCommand: DEADLOCK: cts.Cancel() did not return within 3s
Why it matters
Callers expect Cancel() to be cheap and not to re-enter their code, and the BCL's own Process.WaitForExitAsync behaves that way. With RunCommand, a UI or service that cancels a command while holding a lock its cleanup also takes will deadlock with SemaphoreSlim or other non-reentrant locks. With Monitor, the cleanup silently re-enters the lock instead. Timer threads also end up running process-tree kills and arbitrary user code.
With this change applied locally, the deadlock repro prints Cancel() returned / OK, and the existing suite still passes (55 passed, 2 elevation tests skipped on Linux).
Acceptance criteria
A test that cancels from a thread holding a SemaphoreSlim needed by the awaiting code's catch completes without deadlock.
A test asserting the caller's continuation does not run inside Cancel().
Priority: High. cts.Cancel() runs the process-tree kill and the caller's own catch inline. It deadlocks any caller that cancels while holding a non-reentrant lock that its cleanup also takes, and with CancelAfter it runs user code on the timer thread. RunCommand sits under GitIntegration, SvnToGit and KtsuBuild, so the impact spreads downstream.
Area / suggested assignee:RunCommand/AsyncProcessStreamReader.cs:56. Owner: @matt-edmondson
Next step: The issue already verifies a one-line fix, new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously), against the existing suite. Land it with the SemaphoreSlim deadlock test and the test asserting that the caller's continuation does not run inside Cancel().
Activity
matt-edmondson commentedon Sep 29, 2026
Triage
cts.Cancel()runs the process-tree kill and the caller's owncatchinline. It deadlocks any caller that cancels while holding a non-reentrant lock that its cleanup also takes, and withCancelAfterit runs user code on the timer thread. RunCommand sits under GitIntegration, SvnToGit and KtsuBuild, so the impact spreads downstream.RunCommand/AsyncProcessStreamReader.cs:56. Owner: @matt-edmondsonLineOutputHandlerscanning) touches a nearby area but does not cover this.new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously), against the existing suite. Land it with theSemaphoreSlimdeadlock test and the test asserting that the caller's continuation does not run insideCancel().Generated by Claude Code