Skip to content

Commit

Permalink
feat: support CTRL+C and unload events handling
Browse files Browse the repository at this point in the history
Fixes #111
  • Loading branch information
natemcmaster committed Jul 24, 2019
1 parent 431f273 commit 988c426
Show file tree
Hide file tree
Showing 11 changed files with 108 additions and 67 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
* PR [#239] by [@vpkopylov] - Add check for subcommand cycle
* Support C# 8.0 and nullable reference types - [#245]
* Add async methods to CommandLineApplication
* Handle CTRL+C by default
* Fix [#208] - make `CommandLineApplication.ExecuteAsync` actually asynchronous
* Fix [#153] - add async methods that accept cancellation tokens
* Fix [#111] - Handle CTRL+C by default

[#111]: https://github.com/natemcmaster/CommandLineUtils/issues/111
[#153]: https://github.com/natemcmaster/CommandLineUtils/issues/153
[#208]: https://github.com/natemcmaster/CommandLineUtils/issues/208
[#221]: https://github.com/natemcmaster/CommandLineUtils/issues/221
Expand Down
76 changes: 58 additions & 18 deletions src/CommandLineUtils/CommandLineApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -26,9 +27,24 @@ public partial class CommandLineApplication : IServiceProvider, IDisposable
{
private const int HelpExitCode = 0;
internal const int ValidationErrorExitCode = 1;
private static readonly int ExitCodeOperationCanceled;

static CommandLineApplication()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// values from https://www.febooti.com/products/automation-workshop/online-help/events/run-dos-cmd-command/exit-codes/
ExitCodeOperationCanceled = unchecked((int)0xC000013A);
}
else
{
// Match Process.ExitCode which uses 128 + signo.
ExitCodeOperationCanceled = 130; // SIGINT
}
}

private static Task<int> DefaultAction(CancellationToken ct) => Task.FromResult(0);
private Func<CancellationToken, Task<int>> _action;
private Func<CancellationToken, Task<int>> _handler;
private List<Action<ParseResult>>? _onParsingComplete;
internal readonly Dictionary<string, PropertyInfo> _shortOptions = new Dictionary<string, PropertyInfo>();
internal readonly Dictionary<string, PropertyInfo> _longOptions = new Dictionary<string, PropertyInfo>();
Expand Down Expand Up @@ -103,7 +119,7 @@ internal CommandLineApplication(
Commands = new List<CommandLineApplication>();
RemainingArguments = new List<string>();
_helpTextGenerator = helpTextGenerator ?? throw new ArgumentNullException(nameof(helpTextGenerator));
_action = DefaultAction;
_handler = DefaultAction;
_validationErrorHandler = DefaultValidationErrorHandler;
Out = context.Console.Out;
Error = context.Console.Error;
Expand Down Expand Up @@ -264,8 +280,8 @@ public CommandOption? OptionHelp
[EditorBrowsable(EditorBrowsableState.Never)]
public Func<int> Invoke
{
get => () => _action(GetDefaultCancellationToken()).GetAwaiter().GetResult();
set => _action = _ => Task.FromResult(value());
get => () => _handler(default).GetAwaiter().GetResult();
set => _handler = _ => Task.FromResult(value());
}

/// <summary>
Expand Down Expand Up @@ -667,7 +683,7 @@ public void OnExecute(Func<int> invoke)
/// <param name="invoke"></param>
public void OnExecuteAsync(Func<CancellationToken, Task<int>> invoke)
{
_action = invoke;
_handler = invoke;
}

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

if (cancellationToken == CancellationToken.None)
var handlerCompleted = new ManualResetEventSlim(initialState: false);
var handlerCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

void cancelHandler(object o, ConsoleCancelEventArgs e)
{
handlerCancellationTokenSource.Cancel();
handlerCompleted.Wait();
}

#if !NETSTANDARD1_6
void unloadingHandler(object o, EventArgs e)
{
cancellationToken = GetDefaultCancellationToken();
handlerCancellationTokenSource.Cancel();
handlerCompleted.Wait();
}
#endif

return await command._action(cancellationToken);
try
{
// blocks .NET's CTRL+C handler from completing until after async completions are done
_context.Console.CancelKeyPress += cancelHandler;
#if !NETSTANDARD1_6
// blocks .NET's process unloading from completing until after async completions are done
AppDomain.CurrentDomain.DomainUnload += unloadingHandler;
#endif

return await command._handler(handlerCancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
return ExitCodeOperationCanceled;
}
finally
{
_context.Console.CancelKeyPress -= cancelHandler;
#if !NETSTANDARD1_6
AppDomain.CurrentDomain.DomainUnload -= unloadingHandler;
#endif
handlerCompleted.Set();
}
}

/// <summary>
Expand Down Expand Up @@ -1080,16 +1130,6 @@ internal bool MatchesName(string name)
return _names.Contains(name);
}

internal CancellationToken GetDefaultCancellationToken()
{
if (_context.Console is ICancellationTokenProvider ctp)
{
return ctp.Token;
}

return default;
}

private sealed class Builder : IConventionBuilder
{
private readonly CommandLineApplication _app;
Expand Down
8 changes: 4 additions & 4 deletions src/CommandLineUtils/Conventions/ExecuteMethodConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
using System;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils.Abstractions;

namespace McMaster.Extensions.CommandLineUtils.Conventions
{
Expand All @@ -24,10 +24,10 @@ public virtual void Apply(ConventionContext context)
return;
}

context.Application.OnExecute(async () => await this.OnExecute(context));
context.Application.OnExecuteAsync(async ct => await OnExecute(context, ct));
}

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

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

var arguments = ReflectionHelper.BindParameters(method, context.Application);
var arguments = ReflectionHelper.BindParameters(method, context.Application, cancellationToken);
var modelAccessor = context.ModelAccessor;
if (modelAccessor == null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public virtual void Apply(ConventionContext context)

context.Application.ValidationErrorHandler = (v) =>
{
var arguments = ReflectionHelper.BindParameters(method, context.Application);
var arguments = ReflectionHelper.BindParameters(method, context.Application, default);
var result = method.Invoke(modelAccessor.GetModel(), arguments);
if (method.ReturnType == typeof(int))
{
Expand Down
10 changes: 1 addition & 9 deletions src/CommandLineUtils/IO/PhysicalConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,21 @@

using System;
using System.IO;
using System.Threading;
using McMaster.Extensions.CommandLineUtils.Internal;

namespace McMaster.Extensions.CommandLineUtils
{
/// <summary>
/// An implementation of <see cref="IConsole"/> that wraps <see cref="System.Console"/>.
/// </summary>
public class PhysicalConsole : IConsole, ICancellationTokenProvider
public class PhysicalConsole : IConsole
{
private readonly CancellationTokenSource _cancelKeyPressed;

/// <summary>
/// A shared instance of <see cref="PhysicalConsole"/>.
/// </summary>
public static IConsole Singleton { get; } = new PhysicalConsole();

private PhysicalConsole()
{
_cancelKeyPressed = new CancellationTokenSource();
Console.CancelKeyPress += (_, __) => _cancelKeyPressed.Cancel();
}

/// <summary>
Expand Down Expand Up @@ -84,8 +78,6 @@ public ConsoleColor BackgroundColor
set => Console.BackgroundColor = value;
}

CancellationToken ICancellationTokenProvider.Token => _cancelKeyPressed.Token;

/// <summary>
/// <see cref="Console.ResetColor"/>.
/// </summary>
Expand Down
12 changes: 0 additions & 12 deletions src/CommandLineUtils/Internal/ICancellationTokenProvider.cs

This file was deleted.

6 changes: 3 additions & 3 deletions src/CommandLineUtils/Internal/ReflectionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static MemberInfo[] GetMembers(Type type)
return type.GetTypeInfo().GetMembers(binding);
}

public static object[] BindParameters(MethodInfo method, CommandLineApplication command)
public static object[] BindParameters(MethodInfo method, CommandLineApplication command, CancellationToken cancellationToken)
{
var methodParams = method.GetParameters();
var arguments = new object[methodParams.Length];
Expand All @@ -81,9 +81,9 @@ public static object[] BindParameters(MethodInfo method, CommandLineApplication
{
arguments[i] = command._context;
}
else if (typeof(CancellationToken) == methodParam.ParameterType)
else if (typeof(CancellationToken) == methodParam.ParameterType && cancellationToken != CancellationToken.None)
{
arguments[i] = command.GetDefaultCancellationToken();
arguments[i] = cancellationToken;
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper

<ItemGroup Condition="'$(TargetFramework)' == 'net45'">
<Reference Include="System.ComponentModel.DataAnnotations" />
<PackageReference Include="System.Runtime.InteropServices.RuntimeInformation" Version="4.3.0" />
<PackageReference Include="System.ValueTuple" Version="4.4.0" />
</ItemGroup>

Expand Down
21 changes: 20 additions & 1 deletion test/CommandLineUtils.Tests/CommandLineApplicationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;
Expand Down Expand Up @@ -960,7 +961,7 @@ public async Task AsyncTaskWithoutReturnIsAwaitedAsync()
{
var app = new CommandLineApplication();
var tcs = new TaskCompletionSource<int>();
app.OnExecute(async () =>
app.OnExecuteAsync(async ct =>
{
var val = await tcs.Task.ConfigureAwait(false);
if (val > 0)
Expand All @@ -976,5 +977,23 @@ public async Task AsyncTaskWithoutReturnIsAwaitedAsync()
tcs.TrySetResult(1);
await Assert.ThrowsAsync<InvalidOperationException>(async () => await run);
}

[Fact]
public async Task OperationCanceledReturnsExpectedOsCode()
{
var expectedCode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? unchecked((int)0xC000013A)
: 130;
var testConsole = new TestConsole(_output);
var app = new CommandLineApplication(testConsole);
app.OnExecuteAsync(async ct =>
{
await Task.Delay(-1, ct);
});
var executeTask = app.ExecuteAsync(Array.Empty<string>());
testConsole.RaiseCancelKeyPress();
var exitCode = await executeTask;
Assert.Equal(expectedCode, exitCode);
}
}
}
19 changes: 13 additions & 6 deletions test/CommandLineUtils.Tests/ExecuteMethodConventionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,16 @@ private class ProgramWithAsyncOnExecute
{
public CancellationToken Token { get; private set; }

public Task<int> OnExecuteAsync(CancellationToken ct)
public static TaskCompletionSource<object?> ExecuteStarted = new TaskCompletionSource<object?>();

public async Task<int> OnExecuteAsync(CancellationToken ct)
{
ExecuteStarted.TrySetResult(null);
Token = ct;
return Task.FromResult(4);
var tcs = new TaskCompletionSource<object?>();
ct.Register(() => tcs.TrySetResult(null));
await tcs.Task;
return 4;
}
}

Expand All @@ -126,12 +132,13 @@ public async Task ItExecutesAsyncMethod()
var console = new TestConsole(_output);
var app = new CommandLineApplication<ProgramWithAsyncOnExecute>(console);
app.Conventions.UseOnExecuteMethodFromModel();

var result = await app.ExecuteAsync(Array.Empty<string>());
Assert.Equal(4, result);
var executeTask = app.ExecuteAsync(Array.Empty<string>());
await ProgramWithAsyncOnExecute.ExecuteStarted.Task.ConfigureAwait(false);
Assert.False(app.Model.Token.IsCancellationRequested);
Assert.NotEqual(CancellationToken.None, app.Model.Token);
console.CancelKeyCancellationSource.Cancel();
console.RaiseCancelKeyPress();
var result = await executeTask.ConfigureAwait(false);
Assert.Equal(4, result);
Assert.True(app.Model.Token.IsCancellationRequested);
}
}
Expand Down
17 changes: 5 additions & 12 deletions test/CommandLineUtils.Tests/Utilities/TestConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@

using System;
using System.IO;
using System.Threading;
using McMaster.Extensions.CommandLineUtils.Internal;
using Xunit.Abstractions;

namespace McMaster.Extensions.CommandLineUtils.Tests
{
public class TestConsole : IConsole, ICancellationTokenProvider
public class TestConsole : IConsole
{
public TestConsole(ITestOutputHelper output)
{
Expand All @@ -32,20 +30,15 @@ public TestConsole(ITestOutputHelper output)
public ConsoleColor ForegroundColor { get; set; }
public ConsoleColor BackgroundColor { get; set; }

CancellationToken ICancellationTokenProvider.Token => CancelKeyPressToken;
public event ConsoleCancelEventHandler CancelKeyPress;

public CancellationToken CancelKeyPressToken => CancelKeyCancellationSource.Token;

public CancellationTokenSource CancelKeyCancellationSource { get; } = new CancellationTokenSource();

public event ConsoleCancelEventHandler CancelKeyPress
public void ResetColor()
{
add { }
remove { }
}

public void ResetColor()
public void RaiseCancelKeyPress()
{
CancelKeyPress?.Invoke(this, default);
}
}
}

0 comments on commit 988c426

Please sign in to comment.