Skip to content

Throw on cancellation instead of returning a killed process's exit code - #42

Merged
matt-edmondson merged 1 commit into
mainfrom
fix/38-cancellation-race
Aug 19, 2026
Merged

Throw on cancellation instead of returning a killed process's exit code#42
matt-edmondson merged 1 commit into
mainfrom
fix/38-cancellation-race

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #38.

Problem

RunAsync delivered cancellation two ways at once — a registration that killed the process, and WaitForExitAsync(cancellationToken) racing to observe the same token. Killing the process made it exit fast enough that the normal-exit path could win, in which case the method returned the killed process's exit code (-1 on Windows) and threw nothing.

A consumer mapping non-zero exit codes onto failures reported a cancelled command as a genuine failure of the underlying tool, with empty output and no way to tell the two apart.

Fix

Re-check the token after the wait and before returning, so both paths converge on OperationCanceledException. Covers the elevated and redirected branches alike.

Testing

ExecuteAsyncShouldThrowRatherThanReturnAnExitCodeWhenCancellationWinsTheRace cancels after 1 ms and repeats 50 times. A single attempt still throws roughly 60% of the time with the bug present, so one iteration cannot pin the race down; 50 makes a false pass vanishingly unlikely.

Confirmed failing before the fix (attempt 1 returned an exit code) and passing after. Full suite: 25 passed, 2 skipped (elevated tests self-skip to avoid a UAC prompt).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LwKTWcmxe5mh6DsLRj5NGQ

… exit code

Cancellation reached the wait two ways at once: the token registration
killed the process, and WaitForExitAsync separately observed the token.
Killing the process made it exit quickly enough that the normal-exit
path could complete before the cancellation path faulted the await, so
RunAsync fell through to return the killed process's exit code (-1 on
Windows) and threw nothing.

A caller mapping non-zero exit codes onto failures therefore reported a
cancelled command as a genuine failure of the underlying tool, silently
and non-deterministically.

Re-check the token after the wait so both paths end the same way.

The regression test cancels after 1ms and repeats 50 times: a single
attempt still throws most of the time even with the bug present, so one
iteration cannot pin the race down.

Fixes #38
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 57e306c into main Aug 19, 2026
5 checks passed
@matt-edmondson
matt-edmondson deleted the fix/38-cancellation-race branch August 19, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancellation can return normally instead of throwing, yielding a synthetic exit code

1 participant