Skip to content

On .NET Core 3.x and .NET 5–7, cancelling a command leaves its child processes running, though README says the whole process tree is terminated on .NET Core 3.0+ #134

Open

Description

@matt-edmondson

What's wrong

README.md (line 182) says: "On .NET Core 3.0 and later the entire process tree is terminated. On .NET Standard 2.0 and 2.1 only the process itself can be terminated."

The package doesn't ship builds for .NET Core 3.x or for net5.0, net6.0 or net7.0. ktsu.Sdk sets TargetFrameworks to net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1, and the 1.9.x nupkgs contain only those lib/ folders. An app on .NET Core 3.1 or on .NET 5, 6 or 7 therefore resolves the netstandard2.1 build, which compiles Kill to a plain process.Kill() (RunCommand.cs#L578-L584). On those runtimes a cancelled run kills only the direct child and every grandchild keeps running, even though Process.Kill(bool entireProcessTree) exists on all of them. It just can't be called statically from the netstandard2.1 reference surface.

CLAUDE.md (line 49) also still lists net7.0, net6.0 and net5.0 as targets.

Repro

This was run on Linux, loading each build in a .NET runtime:

using var cts = new CancellationTokenSource(300);
try { await RunCommand.ExecuteAsync("sh", ["-c", "sleep 37; echo done"], new OutputHandler(), cts.Token); }
catch (OperationCanceledException) { }
await Task.Delay(500);
// then: pgrep -a -f '^sleep 37$'
.NETStandard,Version=v2.1
cancelled
surviving 'sleep 37' descendants: 8435 sleep 37
.NETCoreApp,Version=v10.0
cancelled
surviving 'sleep 37' descendants: none

Real commands spawn build servers, test hosts and npm children. On these runtimes those processes outlive the cancellation, although the README promises they are cleaned up.

Suggested fix

In the NETSTANDARD2_0 || NETSTANDARD2_1 branch, look up the overload once at runtime and use it when it is present:

private static readonly MethodInfo? KillTree =
    typeof(Process).GetMethod("Kill", [typeof(bool)]);

private static void Kill(Process process)
{
    if (KillTree is not null)
    {
        try { KillTree.Invoke(process, [true]); }
        catch (TargetInvocationException e) when (e.InnerException is not null)
        { ExceptionDispatchInfo.Capture(e.InnerException).Throw(); }
    }
    else
    {
        process.Kill();
    }
}

Unwrapping TargetInvocationException keeps TryKill's existing handling of AggregateException, InvalidOperationException and Win32Exception (#107) working. On .NET Framework and Mono the method is absent, so behaviour there doesn't change.

Those runtimes are out of support, so fixing only the documentation is a valid alternative. In that case the README needs to say that tree kill applies only to the net8.0+ builds.

Acceptance criteria

  • When loaded on a .NET Core 3.0+ runtime, the netstandard2.1 and netstandard2.0 builds kill grandchildren on cancellation, matching the net8+ builds. Alternatively, the README states exactly which builds get tree kill.
  • A test covers the reflection path and asserts that a sleep grandchild is gone after cancellation.
  • The README's tree-termination sentence and CLAUDE.md's "Process-tree termination" note and target list match what actually ships.

Related: #78, #107.

Activity

  1. matt-edmondson commented on Nov 2, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions