Skip to content

Kill the child when ExecuteAsync is cancelled, as Execute does [patch] - #100

Merged
matt-edmondson merged 4 commits into
mainfrom
fix/async-cancel-kills-child
Oct 8, 2026
Merged

matt-edmondson merged 4 commits into
mainfrom
fix/async-cancel-kills-child

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #53

Problem

When NativeCommandExecutor.ExecuteAsync was cancelled, it returned Operation was cancelled. and disposed the Process. Disposing doesn't end the child, so the command kept running and still performed its side effects. The synchronous Execute already kills the child on cancellation.

Change

  • ExecuteAsync now catches OperationCanceledException inside the scope where the process exists, and calls TryKill(process) before returning Cancelled(). A token that is already cancelled before start still short-circuits as before.
  • The outer OperationCanceledException handler is gone. Every await that can observe the token is now inside the inner try, so that handler could no longer be reached.
  • TryKill uses Kill(entireProcessTree: true) on netcoreapp3.0+, so on both paths any commands the shell started are killed with it. It falls back to Kill() on netstandard2.1.

Tests

  • CommandExecutor_Cancellation_Stops_The_Command runs sleep 10; touch <marker> through each path (ping / type nul on Windows). It cancels after 250 ms and asserts that each run returned well before the 10 s side effect was due. It then waits until the side effect would have happened and asserts that neither marker exists.
  • The first version used a 2 s side effect. Once, on Windows CI, the cancellation was delivered only as the command finished, which made the kill look broken. The longer delay and the elapsed-time assertions make that case fail with an accurate message instead.
  • Proven both ways. Without the new TryKill call in the async handler, the test fails on Assert.IsFalse(File.Exists(asyncMarker)). With the fix, it passes.
  • Full suite on Linux passes. Release build of Essentials.CommandExecutors.Native is clean on all targets.

This merges cleanly with main after #99 (Unix argument quoting).

🤖 Generated with Claude Code

https://claude.ai/code/session_01D7wytU6jsZ1cH3f6grCTz5

Cancelling ExecuteAsync returned "Operation was cancelled." and disposed
the Process, but disposing does not end the child, so the command ran on
to completion and performed its side effects. The cancellation handler
now kills the process before returning, like the synchronous path.

TryKill also kills the whole process tree where the platform supports it
(netcoreapp3.0+), so commands the shell started go with it on both paths.

Fixes #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7wytU6jsZ1cH3f6grCTz5
claude added 3 commits October 7, 2026 12:13
Every await that can observe the token now sits inside the inner try, so
no OperationCanceledException can reach the outer handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7wytU6jsZ1cH3f6grCTz5
With a two-second side effect a loaded Windows runner observed the
250 ms cancellation only as the command finished, so the marker existed
and the test blamed the kill. The command now waits ten seconds, each
run asserts it returned well before that (a run that did not proves
nothing either way), and the final wait is measured from the later
run's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7wytU6jsZ1cH3f6grCTz5
Same wait for a side effect that must not happen, but it ends early when the
test run is cancelled, and it clears Sonar S2925.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7wytU6jsZ1cH3f6grCTz5
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 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

Development

Successfully merging this pull request may close these issues.

Cancelling NativeCommandExecutor.ExecuteAsync reports "cancelled" but leaves the child process running (the sync Execute kills it)

2 participants