dotnet / dotnet/command-line-api

Async pre-actions are not covered by process-termination handling: Ctrl+C hard-kills the process instead of cancelling the token

Open
#2,836 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C#
Stars
3.7k
Forks
428
PR merge metrics
No merged PRs in 30d

Description

## Summary

`InvocationPipeline.InvokeAsync` installs the `ProcessTerminationHandler` only around the
**main command action**, never around **pre-actions**. As a result, when the user presses
Ctrl+C (SIGINT/SIGTERM) while an *async pre-action* is running:

- the `CancellationToken` the pre-action received is **never cancelled**, and
- the OS default signal handling is **not suppressed**, so the process is **hard-terminated
immediately** — no `OperationCanceledException`, no unwind, no cleanup.

The same handler running as the **command action** cancels gracefully. So whether Ctrl+C is
cooperative or fatal depends purely on whether the async work runs in a pre-action or the
command action, which is surprising and undocumented.

## Repro

Minimal console app (`net10.0`) referencing `System.CommandLine`:

```csharp
using System.CommandLine;
using System.CommandLine.Invocation;

var slow = new Option("--slow");
slow.Action = new SlowPreAction(); // non-terminating async action => runs as a PreAction

var root = new RootCommand("repro") { slow };
root.SetAction(async (parseResult, ct) => { // async command action
Console.WriteLine("command action started");
try {
await Task.Delay(TimeSpan.FromSeconds(30), ct);
Console.WriteLine("command action finished");
} catch (OperationCanceledException) {
// this catch works as expected
Console.WriteLine("command action cancelled");
}
return 0;
});

return await root.Parse(args).InvokeAsync();

sealed class SlowPreAction : AsynchronousCommandLineAction {
public override bool Terminating => false;
public override async Task InvokeAsync(ParseResult parseResult, CancellationToken ct) {
Console.WriteLine("pre-action started");
try {
await Task.Delay(TimeSpan.FromSeconds(30), ct);
Console.WriteLine("pre-action finished");
} catch (OperationCanceledException) {
// this catch will not work
Console.WriteLine("pre-action cancelled");
}
return 0;
}
}
```

### Case A — cancel during the command action (works as expected)

```
> repro
command action started
^Ccommand action cancelled

Process finished with exit code 0.

```
The token is cancelled, `Task.Delay` throws `OperationCanceledException`, the process exits
cleanly.

### Case B — cancel during the pre-action (the bug)

```
> repro --slow x
pre-action started
^C
```
The process **exits immediately as if killed** — the token is never cancelled, no exception is
observed, and `pre-action finished` never prints. It behaves as though there were no Ctrl+C
handling installed at all.

## Expected behavior

Ctrl+C during an async pre-action should behave the same as during the command action: the
`CancellationToken` handed to the pre-action is cancelled, the OS default kill is suppressed,
and the pre-action is given the `ProcessTerminationTimeout` grace period to unwind.

## Actual behavior

Pre-actions run with no `ProcessTerminationHandler`. The token is inert and the process is
hard-terminated by the default signal.

## Root cause

In `src/System.CommandLine/Invocation/InvocationPipeline.cs`, `InvokeAsync`:

- **Pre-actions** are awaited in the loop with no termination handler:

```csharp
case AsynchronousCommandLineAction asyncAction:
result = await asyncAction.InvokeAsync(parseResult, cts.Token); // no ProcessTerminationHandler
break;
```

- The `ProcessTerminationHandler` — which registers the SIGINT/SIGTERM handler
(`ProcessTerminationHandler.cs`, `PosixSignalRegistration.Create(...)`), sets
`context.Cancel = true` to suppress the default kill, and cancels the linked `cts` — is
created **only** for the main command action:

```csharp
var timeout = parseResult.InvocationConfiguration.ProcessTerminationTimeout;
if (timeout.HasValue) terminationHandler = new(cts, timeout.Value);
var startedInvocation = asyncAction.InvokeAsync(parseResult, cts.Token);
...
```

Because no handler is installed during the pre-action phase, nothing ever cancels `cts` there,
and SIGINT/SIGTERM fall through to the runtime default (terminate the process).

Note: even setting aside the hard-kill, a perfectly cooperative pre-action could never observe
cancellation, since `cts` is not cancellable during that phase.

## Suggested fix

Install the process-termination handling around the entire async invocation (pre-actions +
command action), not just the command action — e.g. create the `ProcessTerminationHandler`
before the pre-action loop so SIGINT is intercepted and `cts` is cancellable throughout. At
minimum, pre-actions should receive a token that is actually cancelled on Ctrl+C and should not
be hard-killed mid-run.

## Environment

- `System.CommandLine` 3.0.0-preview.5.26302.115 (also confirmed present on `main`)
- .NET SDK 10.0.100
- Reproduced on macOS (darwin); the code path is platform-independent (both the
`PosixSignalRegistration` and `Console.CancelKeyPress` branches are gated inside the
handler that pre-actions never construct).

▎ Drafted with AI assistance; I reproduced the behavior on 3.0.0-preview.5, confirmed the same code path on main, and verified the root-cause references myself.

Contributor guide

Open the contributing guide

Research direction

Start in src/System.CommandLine/Invocation/InvocationPipeline.cs at InvokeAsync and compare the pre-action loop with the main command-action path. Read ProcessTerminationHandler.cs and run the provided net10.0 reproduction with Ctrl+C during the pre-action. Done means the pre-action token is cancelled, the signal is suppressed, and the action can unwind within the configured grace period.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
cli
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.