Skip to content

Add AbandonOnRepeatCancellation extension method #1227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions System.CommandLine.sln
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.CommandLine.Hosting.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostingPlayground", "samples\HostingPlayground\HostingPlayground.csproj", "{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.CommandLine.ManualTest.AbandonOnRepeatCancellation", "src\System.CommandLine.ManualTest.AbandonOnRepeatCancellation\System.CommandLine.ManualTest.AbandonOnRepeatCancellation.csproj", "{4A15B6E9-5509-40B3-9706-37DFF334D162}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -236,6 +238,18 @@ Global
{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x64.Build.0 = Release|Any CPU
{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.ActiveCfg = Release|Any CPU
{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.Build.0 = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|x64.ActiveCfg = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|x64.Build.0 = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|x86.ActiveCfg = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Debug|x86.Build.0 = Debug|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|Any CPU.Build.0 = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|x64.ActiveCfg = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|x64.Build.0 = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|x86.ActiveCfg = Release|Any CPU
{4A15B6E9-5509-40B3-9706-37DFF334D162}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -255,6 +269,7 @@ Global
{644C4B4A-4A32-4307-9F71-C3BF901FFB66} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45}
{39483140-BC26-4CAD-BBAE-3DC76C2F16CF} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45}
{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906} = {6749FB3E-39DE-4321-A39E-525278E9408D}
{4A15B6E9-5509-40B3-9706-37DFF334D162} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5C159F93-800B-49E7-9905-EE09F8B8434A}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.

using System.Diagnostics.CodeAnalysis;

[assembly: SuppressMessage("Reliability",
"CA2016: Forward the 'CancellationToken' parameter to methods that take one",
Justification = "Test is designed to illustrate behaviour with abandonment when cancellation token is not observed.")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.CommandLine.IO;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace System.CommandLine.ManualTest.AbandonOnRepeatCancellation
{
public static class Program
{
public static async Task Main(string[] args)
{
var command = new RootCommand
{
Handler = CommandHandler.Create(
async (IConsole console, CancellationToken cancelToken) =>
{
console.Out.WriteLine("Invocation started. Press Ctrl+C to request cancellation");
using var cancelReg = cancelToken.Register(state =>
{
var console = (IConsole)state;
console.Out.WriteLine("Cancellation requested. Press Ctrl+C again, to abandon invocation.");
}, console);
var stopwatch = new Stopwatch();
stopwatch.Start();
while (true)
{
console.Out.WriteLine($"Time since start: {stopwatch.Elapsed}, Is cancellation requested: {cancelToken.IsCancellationRequested}");
await Task.Delay(TimeSpan.FromSeconds(0.75))
.ConfigureAwait(continueOnCapturedContext: false);
}
}),
};
var parser = new CommandLineBuilder(command)
.CancelOnProcessTermination()
.AbandonOnRepeatCancellation()
.Build();
try
{
await parser
.InvokeAsync(args ?? Array.Empty<string>())
.ConfigureAwait(continueOnCapturedContext: false);
}
catch (OperationCanceledException)
{
Console.WriteLine("Invocation was cancelled or abandoned.");
}

Console.WriteLine($"Back in {nameof(Main)}, waiting 5 seconds to exit.");
await Task.Delay(TimeSpan.FromSeconds(5))
.ConfigureAwait(continueOnCapturedContext: false);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\System.CommandLine\System.CommandLine.csproj" />
</ItemGroup>

</Project>
47 changes: 47 additions & 0 deletions src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

using static System.Environment;

using Process = System.CommandLine.Invocation.Process;

namespace System.CommandLine.Builder
Expand Down Expand Up @@ -127,6 +129,51 @@ public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuil
return builder;
}

public static CommandLineBuilder AbandonOnRepeatCancellation(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if enabling this functionality would make sense as a parameter to CancelOnProcessTermination rather than a separate method.

Thoughts, @tmds?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe as an optional parameter with a struct/class containing behaviour options. Among them bool abandon and int threshold?

Copy link
Member

@tmds tmds Apr 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think we should add it to CancelOnProcessTermination to avoid weird interactions like https://github.com/dotnet/command-line-api/pull/1227/files#r605602596.

CancelOnProcessTermination gets included by UseDefaults. There is no pattern for passing patterns to the default middlewares that are used by UseDefaults.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there tools that you've seen where the force quit behavior would involve more than two CTRL-C presses? I'm wondering if the threshold needs to be configurable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, okay, we don't need a threshold. But @tmds has a good point. Should UseDefault include this abandoning behaviour? How would someone use UseDefault and abandon on secondary cancel?
Should we maybe also inject a state object into the Invocation that keeps a count of cancellations, so that people could react to a secondary cancel? In hosting the StopAsync method accepts a cancellation token to abort graceful shutdown and just force shutdown the host. We could use such an injected status object to control cancellation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition is that this behavior should be enabled by default so that the path of least resistance for the developer leads to the most consistent experience for end users.

@KathleenDollard?

this CommandLineBuilder builder,
int repeatThreshold = 1)
{
builder.AddMiddleware(async (context, next) =>
{
var initialCancelToken = context.GetCancellationToken();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't call GetCancellationToken here. When the CancellationToken gets requested (which is assumed to be done by the user), the context assumes that CancellationToken will be used to implement Ctrl+C handling, and the app no longer terminates by default.

So this has the unintended side-effect that command handlers which don't use a CancellationToken no longer terminate on first Ctrl+C.

var repeatCancelTaskCompletionSource = new TaskCompletionSource<object>(
TaskCreationOptions.RunContinuationsAsynchronously);
ConsoleCancelEventHandler repeatCancelEventHandler = (sender, e) =>
{
var newCountValue = Interlocked.Decrement(ref repeatThreshold);
if (newCountValue < 1)
{
// Won't block because TCS specifies RunContinuationsAsynchronously
_ = repeatCancelTaskCompletionSource.TrySetCanceled();
}
};

using var initialCancelRegistration = initialCancelToken.Register(state =>
{
Console.CancelKeyPress += (ConsoleCancelEventHandler)state;
}, repeatCancelEventHandler);

try
{
// Next invocation might be a synchronous implementation
// Use Task.Run to be able to call Task.WhenAny.
var nextTask = Task.Run(() => next(context));
var repeatCancelTask = repeatCancelTaskCompletionSource.Task;
var returnedTask = await Task.WhenAny(nextTask, repeatCancelTask)
.ConfigureAwait(continueOnCapturedContext: false);
// Will always execute synchronously, since task is guaranteed to be in final state
returnedTask.GetAwaiter().GetResult();
}
finally
{
Console.CancelKeyPress -= repeatCancelEventHandler;
}

}, MiddlewareOrderInternal.ExceptionHandler);

return builder;
}

public static CommandLineBuilder ConfigureConsole(
this CommandLineBuilder builder,
Func<BindingContext, IConsole> createConsole)
Expand Down