Skip to content

[patch] Return from Cancel() before the cancelled run unwinds, so cancelling under a lock can't deadlock - #111

Merged
matt-edmondson merged 1 commit into
mainfrom
fix/103-cancel-continuations-async
Oct 6, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
fix/103-cancel-continuations-async

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #103

Problem

AsyncProcessStreamReader.Start created its cancellation signal as new TaskCompletionSource<bool>(). The token registration completes it from inside CancellationTokenSource.Cancel(), so every continuation ran synchronously on the cancelling thread: the read drain, RunAsync's catch with the process-tree kill, and then the caller's own await/catch. All of that happened before Cancel() returned. A caller that cancelled while holding a lock its cleanup also takes deadlocked, and CancelAfter ran user code on the timer thread.

Change

  • The source is now created with TaskCreationOptions.RunContinuationsAsynchronously, with a comment explaining why.

Tests

Two new tests in RunCommandTests, following the issue's acceptance criteria:

  • CancelShouldNotRunTheCallersContinuationInline: the caller's catch (OperationCanceledException) records whether it ran while Cancel() was still on the stack.
  • CancelWhileHoldingALockTheCallersCleanupNeedsShouldNotDeadlock: the cancelling thread holds a SemaphoreSlim that the caller's catch takes synchronously. The wait in the catch is bounded at 5 s, so the bug shows up as a failure and doesn't hang the run.

Checked locally (Linux, net10.0):

  • Without the fix, both tests fail. The second one fails after its 5 s bound, because Cancel() was blocked behind the cleanup.
  • With the fix, both pass across 5 repeated runs. The full suite passes: 59 passed, 2 skipped (the elevation tests).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bm33TiYrofqKs4oGc3UGUU


Generated by Claude Code

…so Cancel() returns before the unwind

The reader's cancellation TaskCompletionSource was completed from inside
CancellationTokenSource.Cancel() without RunContinuationsAsynchronously, so the
whole unwind ran on the cancelling thread before Cancel() returned: the read
drain, the process-tree kill, and the caller's own catch block. A caller that
cancelled while holding a lock its cleanup also takes deadlocked.

Fixes #103

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Bm33TiYrofqKs4oGc3UGUU
Comment thread RunCommand.Test/RunCommandTests.cs
Comment thread RunCommand.Test/RunCommandTests.cs
@sonarqubecloud

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

1 participant