Skip to content

Commit 988c426

Browse files
committed
feat: support CTRL+C and unload events handling
Fixes #111
1 parent 431f273 commit 988c426

File tree

11 files changed

+108
-67
lines changed

11 files changed

+108
-67
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
* PR [#239] by [@vpkopylov] - Add check for subcommand cycle
1111
* Support C# 8.0 and nullable reference types - [#245]
1212
* Add async methods to CommandLineApplication
13-
* Handle CTRL+C by default
1413
* Fix [#208] - make `CommandLineApplication.ExecuteAsync` actually asynchronous
1514
* Fix [#153] - add async methods that accept cancellation tokens
15+
* Fix [#111] - Handle CTRL+C by default
1616

17+
[#111]: https://github.com/natemcmaster/CommandLineUtils/issues/111
1718
[#153]: https://github.com/natemcmaster/CommandLineUtils/issues/153
1819
[#208]: https://github.com/natemcmaster/CommandLineUtils/issues/208
1920
[#221]: https://github.com/natemcmaster/CommandLineUtils/issues/221

src/CommandLineUtils/CommandLineApplication.cs

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.IO;
99
using System.Linq;
1010
using System.Reflection;
11+
using System.Runtime.InteropServices;
1112
using System.Text;
1213
using System.Threading;
1314
using System.Threading.Tasks;
@@ -26,9 +27,24 @@ public partial class CommandLineApplication : IServiceProvider, IDisposable
2627
{
2728
private const int HelpExitCode = 0;
2829
internal const int ValidationErrorExitCode = 1;
30+
private static readonly int ExitCodeOperationCanceled;
31+
32+
static CommandLineApplication()
33+
{
34+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
35+
{
36+
// values from https://www.febooti.com/products/automation-workshop/online-help/events/run-dos-cmd-command/exit-codes/
37+
ExitCodeOperationCanceled = unchecked((int)0xC000013A);
38+
}
39+
else
40+
{
41+
// Match Process.ExitCode which uses 128 + signo.
42+
ExitCodeOperationCanceled = 130; // SIGINT
43+
}
44+
}
2945

3046
private static Task<int> DefaultAction(CancellationToken ct) => Task.FromResult(0);
31-
private Func<CancellationToken, Task<int>> _action;
47+
private Func<CancellationToken, Task<int>> _handler;
3248
private List<Action<ParseResult>>? _onParsingComplete;
3349
internal readonly Dictionary<string, PropertyInfo> _shortOptions = new Dictionary<string, PropertyInfo>();
3450
internal readonly Dictionary<string, PropertyInfo> _longOptions = new Dictionary<string, PropertyInfo>();
@@ -103,7 +119,7 @@ internal CommandLineApplication(
103119
Commands = new List<CommandLineApplication>();
104120
RemainingArguments = new List<string>();
105121
_helpTextGenerator = helpTextGenerator ?? throw new ArgumentNullException(nameof(helpTextGenerator));
106-
_action = DefaultAction;
122+
_handler = DefaultAction;
107123
_validationErrorHandler = DefaultValidationErrorHandler;
108124
Out = context.Console.Out;
109125
Error = context.Console.Error;
@@ -264,8 +280,8 @@ public CommandOption? OptionHelp
264280
[EditorBrowsable(EditorBrowsableState.Never)]
265281
public Func<int> Invoke
266282
{
267-
get => () => _action(GetDefaultCancellationToken()).GetAwaiter().GetResult();
268-
set => _action = _ => Task.FromResult(value());
283+
get => () => _handler(default).GetAwaiter().GetResult();
284+
set => _handler = _ => Task.FromResult(value());
269285
}
270286

271287
/// <summary>
@@ -667,7 +683,7 @@ public void OnExecute(Func<int> invoke)
667683
/// <param name="invoke"></param>
668684
public void OnExecuteAsync(Func<CancellationToken, Task<int>> invoke)
669685
{
670-
_action = invoke;
686+
_handler = invoke;
671687
}
672688

673689
/// <summary>
@@ -810,12 +826,46 @@ public async Task<int> ExecuteAsync(string[] args, CancellationToken cancellatio
810826
return command.ValidationErrorHandler(validationResult);
811827
}
812828

813-
if (cancellationToken == CancellationToken.None)
829+
var handlerCompleted = new ManualResetEventSlim(initialState: false);
830+
var handlerCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
831+
832+
void cancelHandler(object o, ConsoleCancelEventArgs e)
833+
{
834+
handlerCancellationTokenSource.Cancel();
835+
handlerCompleted.Wait();
836+
}
837+
838+
#if !NETSTANDARD1_6
839+
void unloadingHandler(object o, EventArgs e)
814840
{
815-
cancellationToken = GetDefaultCancellationToken();
841+
handlerCancellationTokenSource.Cancel();
842+
handlerCompleted.Wait();
816843
}
844+
#endif
817845

818-
return await command._action(cancellationToken);
846+
try
847+
{
848+
// blocks .NET's CTRL+C handler from completing until after async completions are done
849+
_context.Console.CancelKeyPress += cancelHandler;
850+
#if !NETSTANDARD1_6
851+
// blocks .NET's process unloading from completing until after async completions are done
852+
AppDomain.CurrentDomain.DomainUnload += unloadingHandler;
853+
#endif
854+
855+
return await command._handler(handlerCancellationTokenSource.Token);
856+
}
857+
catch (OperationCanceledException)
858+
{
859+
return ExitCodeOperationCanceled;
860+
}
861+
finally
862+
{
863+
_context.Console.CancelKeyPress -= cancelHandler;
864+
#if !NETSTANDARD1_6
865+
AppDomain.CurrentDomain.DomainUnload -= unloadingHandler;
866+
#endif
867+
handlerCompleted.Set();
868+
}
819869
}
820870

821871
/// <summary>
@@ -1080,16 +1130,6 @@ internal bool MatchesName(string name)
10801130
return _names.Contains(name);
10811131
}
10821132

1083-
internal CancellationToken GetDefaultCancellationToken()
1084-
{
1085-
if (_context.Console is ICancellationTokenProvider ctp)
1086-
{
1087-
return ctp.Token;
1088-
}
1089-
1090-
return default;
1091-
}
1092-
10931133
private sealed class Builder : IConventionBuilder
10941134
{
10951135
private readonly CommandLineApplication _app;

src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
using System;
55
using System.Reflection;
66
using System.Runtime.ExceptionServices;
7+
using System.Threading;
78
using System.Threading.Tasks;
8-
using McMaster.Extensions.CommandLineUtils.Abstractions;
99

1010
namespace McMaster.Extensions.CommandLineUtils.Conventions
1111
{
@@ -24,10 +24,10 @@ public virtual void Apply(ConventionContext context)
2424
return;
2525
}
2626

27-
context.Application.OnExecute(async () => await this.OnExecute(context));
27+
context.Application.OnExecuteAsync(async ct => await OnExecute(context, ct));
2828
}
2929

30-
private async Task<int> OnExecute(ConventionContext context)
30+
private async Task<int> OnExecute(ConventionContext context, CancellationToken cancellationToken)
3131
{
3232
const BindingFlags binding = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
3333

@@ -56,7 +56,7 @@ private async Task<int> OnExecute(ConventionContext context)
5656
throw new InvalidOperationException(Strings.NoOnExecuteMethodFound);
5757
}
5858

59-
var arguments = ReflectionHelper.BindParameters(method, context.Application);
59+
var arguments = ReflectionHelper.BindParameters(method, context.Application, cancellationToken);
6060
var modelAccessor = context.ModelAccessor;
6161
if (modelAccessor == null)
6262
{

src/CommandLineUtils/Conventions/ValidationErrorMethodConvention.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public virtual void Apply(ConventionContext context)
3333

3434
context.Application.ValidationErrorHandler = (v) =>
3535
{
36-
var arguments = ReflectionHelper.BindParameters(method, context.Application);
36+
var arguments = ReflectionHelper.BindParameters(method, context.Application, default);
3737
var result = method.Invoke(modelAccessor.GetModel(), arguments);
3838
if (method.ReturnType == typeof(int))
3939
{

src/CommandLineUtils/IO/PhysicalConsole.cs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,21 @@
44

55
using System;
66
using System.IO;
7-
using System.Threading;
8-
using McMaster.Extensions.CommandLineUtils.Internal;
97

108
namespace McMaster.Extensions.CommandLineUtils
119
{
1210
/// <summary>
1311
/// An implementation of <see cref="IConsole"/> that wraps <see cref="System.Console"/>.
1412
/// </summary>
15-
public class PhysicalConsole : IConsole, ICancellationTokenProvider
13+
public class PhysicalConsole : IConsole
1614
{
17-
private readonly CancellationTokenSource _cancelKeyPressed;
18-
1915
/// <summary>
2016
/// A shared instance of <see cref="PhysicalConsole"/>.
2117
/// </summary>
2218
public static IConsole Singleton { get; } = new PhysicalConsole();
2319

2420
private PhysicalConsole()
2521
{
26-
_cancelKeyPressed = new CancellationTokenSource();
27-
Console.CancelKeyPress += (_, __) => _cancelKeyPressed.Cancel();
2822
}
2923

3024
/// <summary>
@@ -84,8 +78,6 @@ public ConsoleColor BackgroundColor
8478
set => Console.BackgroundColor = value;
8579
}
8680

87-
CancellationToken ICancellationTokenProvider.Token => _cancelKeyPressed.Token;
88-
8981
/// <summary>
9082
/// <see cref="Console.ResetColor"/>.
9183
/// </summary>

src/CommandLineUtils/Internal/ICancellationTokenProvider.cs

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/CommandLineUtils/Internal/ReflectionHelper.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public static MemberInfo[] GetMembers(Type type)
5656
return type.GetTypeInfo().GetMembers(binding);
5757
}
5858

59-
public static object[] BindParameters(MethodInfo method, CommandLineApplication command)
59+
public static object[] BindParameters(MethodInfo method, CommandLineApplication command, CancellationToken cancellationToken)
6060
{
6161
var methodParams = method.GetParameters();
6262
var arguments = new object[methodParams.Length];
@@ -81,9 +81,9 @@ public static object[] BindParameters(MethodInfo method, CommandLineApplication
8181
{
8282
arguments[i] = command._context;
8383
}
84-
else if (typeof(CancellationToken) == methodParam.ParameterType)
84+
else if (typeof(CancellationToken) == methodParam.ParameterType && cancellationToken != CancellationToken.None)
8585
{
86-
arguments[i] = command.GetDefaultCancellationToken();
86+
arguments[i] = cancellationToken;
8787
}
8888
else
8989
{

src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper
2828

2929
<ItemGroup Condition="'$(TargetFramework)' == 'net45'">
3030
<Reference Include="System.ComponentModel.DataAnnotations" />
31+
<PackageReference Include="System.Runtime.InteropServices.RuntimeInformation" Version="4.3.0" />
3132
<PackageReference Include="System.ValueTuple" Version="4.4.0" />
3233
</ItemGroup>
3334

test/CommandLineUtils.Tests/CommandLineApplicationTests.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System;
66
using System.IO;
77
using System.Linq;
8+
using System.Runtime.InteropServices;
89
using System.Threading.Tasks;
910
using Xunit;
1011
using Xunit.Abstractions;
@@ -960,7 +961,7 @@ public async Task AsyncTaskWithoutReturnIsAwaitedAsync()
960961
{
961962
var app = new CommandLineApplication();
962963
var tcs = new TaskCompletionSource<int>();
963-
app.OnExecute(async () =>
964+
app.OnExecuteAsync(async ct =>
964965
{
965966
var val = await tcs.Task.ConfigureAwait(false);
966967
if (val > 0)
@@ -976,5 +977,23 @@ public async Task AsyncTaskWithoutReturnIsAwaitedAsync()
976977
tcs.TrySetResult(1);
977978
await Assert.ThrowsAsync<InvalidOperationException>(async () => await run);
978979
}
980+
981+
[Fact]
982+
public async Task OperationCanceledReturnsExpectedOsCode()
983+
{
984+
var expectedCode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
985+
? unchecked((int)0xC000013A)
986+
: 130;
987+
var testConsole = new TestConsole(_output);
988+
var app = new CommandLineApplication(testConsole);
989+
app.OnExecuteAsync(async ct =>
990+
{
991+
await Task.Delay(-1, ct);
992+
});
993+
var executeTask = app.ExecuteAsync(Array.Empty<string>());
994+
testConsole.RaiseCancelKeyPress();
995+
var exitCode = await executeTask;
996+
Assert.Equal(expectedCode, exitCode);
997+
}
979998
}
980999
}

test/CommandLineUtils.Tests/ExecuteMethodConventionTests.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,16 @@ private class ProgramWithAsyncOnExecute
113113
{
114114
public CancellationToken Token { get; private set; }
115115

116-
public Task<int> OnExecuteAsync(CancellationToken ct)
116+
public static TaskCompletionSource<object?> ExecuteStarted = new TaskCompletionSource<object?>();
117+
118+
public async Task<int> OnExecuteAsync(CancellationToken ct)
117119
{
120+
ExecuteStarted.TrySetResult(null);
118121
Token = ct;
119-
return Task.FromResult(4);
122+
var tcs = new TaskCompletionSource<object?>();
123+
ct.Register(() => tcs.TrySetResult(null));
124+
await tcs.Task;
125+
return 4;
120126
}
121127
}
122128

@@ -126,12 +132,13 @@ public async Task ItExecutesAsyncMethod()
126132
var console = new TestConsole(_output);
127133
var app = new CommandLineApplication<ProgramWithAsyncOnExecute>(console);
128134
app.Conventions.UseOnExecuteMethodFromModel();
129-
130-
var result = await app.ExecuteAsync(Array.Empty<string>());
131-
Assert.Equal(4, result);
135+
var executeTask = app.ExecuteAsync(Array.Empty<string>());
136+
await ProgramWithAsyncOnExecute.ExecuteStarted.Task.ConfigureAwait(false);
132137
Assert.False(app.Model.Token.IsCancellationRequested);
133138
Assert.NotEqual(CancellationToken.None, app.Model.Token);
134-
console.CancelKeyCancellationSource.Cancel();
139+
console.RaiseCancelKeyPress();
140+
var result = await executeTask.ConfigureAwait(false);
141+
Assert.Equal(4, result);
135142
Assert.True(app.Model.Token.IsCancellationRequested);
136143
}
137144
}

0 commit comments

Comments
 (0)