Skip to content

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

Description

@matt-edmondson

What's wrong

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:

  1. The DrainOrAbandon continuation runs.
  2. RunAsync resumes, and WaitForExitAsync(alreadyCancelledToken) throws.
  3. The catch at RunCommand.cs:504-512 runs TryKill, which kills the whole process tree.
  4. ExecuteAsync faults with OperationCanceledException.
  5. The caller's await / 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.

Suggested fix

TaskCompletionSource<bool> cancellationSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

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().

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions