Skip to content

Kill the process tree whenever a cancelled wait ends the call [patch] - #94

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/kill-on-cancel
Sep 29, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/kill-on-cancel

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

A cancelled ExecuteAsync could return with the whole process tree still running.

Cause

RunAsync relied on cancellationToken.Register(() => TryKill(process)) alone to kill the process. A CancellationTokenSource runs its callbacks newest first, and WaitForExitAsync(cancellationToken) registers its own callback after the kill's. So the wait can observe cancellation first. It then completes the Task.WhenAll, ends the call, and disposes the kill registration, all before the kill callback has run. Disposing a registration whose callback hasn't started removes it, so the kill is skipped or lands late.

Fix

The wait is now wrapped in a try. On OperationCanceledException it calls TryKill(process) before rethrowing, so the kill is issued before the call returns. TryKill already checks HasExited, so a second kill is a no-op.

Where it showed up

GitBranchStateCache#51 moves GitRunner onto RunCommand.ExecuteAsync. There, RunAsync_ExceedingItsTimeout_ReportsTimedOutAndKillsTheTree failed on the ubuntu and windows CI legs. sh and its sleep were both still alive after the timeout.

I ran that test locally with GitBranchStateCache pointed at this project:

  • against main: 3 of 8 runs failed
  • with this fix: 0 of 8 runs failed, twice over

Test

ExecuteAsyncShouldKillTheProcessTreeEveryTimeItIsCancelled runs sh -c 'sleep 30 & echo $!; wait' 40 times. Each time it cancels through a 200 ms timeout linked into the token, which is the GitRunner shape and cancels on a timer thread. It then requires each sleep to have exited within 5 s.

  • Reading exit state: it reads /proc/<pid>/stat rather than using Process.HasExited. Once its parent is killed, the sleep is reparented, and it can sit as a zombie until its new parent reaps it. A zombie has exited, so it counts as gone. I found this matters: counting zombies as alive makes the check fail with or without the fix.
  • Results: against main, 3 of 5 runs failed. With the fix, 0 of 5 failed. The test takes about 10 s.
  • Platforms: Linux only (Assert.Inconclusive elsewhere, matching the neighbouring descendant test). The code it covers is platform independent.

Full suite: 52 passed, 2 skipped (the Windows-only elevation self-skips).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TAvt7dvjcukkH3y6mtUL16


Generated by Claude Code

RunAsync relied on a cancellation registration alone to kill the process.
A token runs its callbacks newest first, and WaitForExitAsync registers its
own after the kill's. The wait could therefore observe cancellation, end the
call, and dispose the kill registration before that callback had run. The
call returned OperationCanceledException with the whole process tree still
running.

The wait is now wrapped so that a cancelled wait calls TryKill itself before
rethrowing. A second kill of a process that has already gone is a no-op.

The new test cancels a command with a descendant 40 times through a linked
timeout token, which cancels on a timer thread. It requires each descendant
to have exited. It reads /proc so a reparented zombie counts as gone. Before
the fix it failed in 3 of 5 runs locally; after the fix, 0 of 5.

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 07ba73a into main Sep 29, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/kill-on-cancel branch September 29, 2026 01:04
matt-edmondson pushed a commit that referenced this pull request Sep 29, 2026
Resolves the conflicts with #94 (kill on cancellation) and #97 (spin
regression test). The read-fault kill now sits inside the else branch of
#94's try/catch (OperationCanceledException), and the tests from both
sides are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6
matt-edmondson pushed a commit to ktsu-dev/GitBranchStateCache that referenced this pull request Sep 29, 2026
RunCommand 1.9.1 kills the process tree whenever a cancelled wait ends the
call (ktsu-dev/RunCommand#94). Before that, GitRunner's timeout could return
with the tree still running, which failed
RunAsync_ExceedingItsTimeout_ReportsTimedOutAndKillsTheTree on every CI leg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAvt7dvjcukkH3y6mtUL16
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.

2 participants