From 457e8933553b4c0f9a7b10ca41def6d8f47f3da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Fri, 12 Mar 2021 15:07:00 +0100 Subject: [PATCH 1/3] Add AbandonOnRepeatCancellation extension method --- .../Builder/CommandLineBuilderExtensions.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs b/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs index 4e193ea527..b4d8719512 100644 --- a/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs +++ b/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs @@ -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 @@ -127,6 +129,45 @@ public static CommandLineBuilder CancelOnProcessTermination(this CommandLineBuil return builder; } + public static CommandLineBuilder AbandonOnRepeatCancellation( + this CommandLineBuilder builder, + int repeatThreshold = 1) + { + builder.AddMiddleware(async (context, next) => + { + var initialCancelToken = context.GetCancellationToken(); + var repeatCancelTaskCompletionSource = new TaskCompletionSource(); + ConsoleCancelEventHandler repeatCancelEventHandler = (sender, e) => + { + var newCountValue = Interlocked.Decrement(ref repeatThreshold); + if (newCountValue < 1) + { + _ = repeatCancelTaskCompletionSource.TrySetCanceled(); + } + }; + + using var initialCancelRegistration = initialCancelToken.Register(state => + { + Console.CancelKeyPress += (ConsoleCancelEventHandler)state; + }, repeatCancelEventHandler); + + try + { + using var nextTask = next(context); + using var repeatCancelTask = repeatCancelTaskCompletionSource.Task; + var returnedTask = await Task.WhenAny(nextTask, repeatCancelTask) + .ConfigureAwait(continueOnCapturedContext: false); + } + finally + { + Console.CancelKeyPress -= repeatCancelEventHandler; + } + + }, MiddlewareOrderInternal.ExceptionHandler); + + return builder; + } + public static CommandLineBuilder ConfigureConsole( this CommandLineBuilder builder, Func createConsole) From c2ca54ad565791fcc6e124219d39d2ee93fa038d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Sun, 21 Mar 2021 13:28:55 +0100 Subject: [PATCH 2/3] Adjustments to AbandonOnRepeatCancellation --- .../Builder/CommandLineBuilderExtensions.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs b/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs index b4d8719512..82a829dcda 100644 --- a/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs +++ b/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs @@ -136,12 +136,14 @@ public static CommandLineBuilder AbandonOnRepeatCancellation( builder.AddMiddleware(async (context, next) => { var initialCancelToken = context.GetCancellationToken(); - var repeatCancelTaskCompletionSource = new TaskCompletionSource(); + var repeatCancelTaskCompletionSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); ConsoleCancelEventHandler repeatCancelEventHandler = (sender, e) => { var newCountValue = Interlocked.Decrement(ref repeatThreshold); if (newCountValue < 1) { + // Won't block because TCS specifies RunContinuationsAsynchronously _ = repeatCancelTaskCompletionSource.TrySetCanceled(); } }; @@ -153,10 +155,14 @@ public static CommandLineBuilder AbandonOnRepeatCancellation( try { - using var nextTask = next(context); - using var repeatCancelTask = repeatCancelTaskCompletionSource.Task; + // 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 { From 5e03ff40e53169b5e03cb2f187bfc28cae8492b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Sun, 21 Mar 2021 13:29:21 +0100 Subject: [PATCH 3/3] Add manual testing project for AbandonOnRepeatCancellation --- System.CommandLine.sln | 15 +++++ .../GlobalSuppressions.cs | 10 ++++ .../Program.cs | 56 +++++++++++++++++++ ...ualTest.AbandonOnRepeatCancellation.csproj | 12 ++++ 4 files changed, 93 insertions(+) create mode 100644 src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/GlobalSuppressions.cs create mode 100644 src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/Program.cs create mode 100644 src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/System.CommandLine.ManualTest.AbandonOnRepeatCancellation.csproj diff --git a/System.CommandLine.sln b/System.CommandLine.sln index bb83d4f820..53d4e90334 100644 --- a/System.CommandLine.sln +++ b/System.CommandLine.sln @@ -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 @@ -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 @@ -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} diff --git a/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/GlobalSuppressions.cs b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/GlobalSuppressions.cs new file mode 100644 index 0000000000..5661a6fc8f --- /dev/null +++ b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/GlobalSuppressions.cs @@ -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.")] diff --git a/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/Program.cs b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/Program.cs new file mode 100644 index 0000000000..41db8da129 --- /dev/null +++ b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/Program.cs @@ -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()) + .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); + } + } +} diff --git a/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/System.CommandLine.ManualTest.AbandonOnRepeatCancellation.csproj b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/System.CommandLine.ManualTest.AbandonOnRepeatCancellation.csproj new file mode 100644 index 0000000000..b83e97254a --- /dev/null +++ b/src/System.CommandLine.ManualTest.AbandonOnRepeatCancellation/System.CommandLine.ManualTest.AbandonOnRepeatCancellation.csproj @@ -0,0 +1,12 @@ + + + + Exe + net5.0 + + + + + + +