Skip to content

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

Description

@RushuiGuan

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:

using System.CommandLine;
using System.CommandLine.Invocation;

var slow = new Option<string>("--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<int> 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:

    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:

    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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions