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
- 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
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